From e896e6af60ac4acda32c40a9f02dd64463088b1d Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:26:58 +0200 Subject: [PATCH] refactor(ui-new): Home, splash, CardKB and the perf walk out of UITask.cpp A pure move: each block now sits in its own header, included where its code was, so the order of definitions is unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/solo/developer/ui-framework.md | 4 +- examples/companion_radio/ui-core/Lettering.h | 2 +- examples/companion_radio/ui-new/CardKB.h | 230 ++ examples/companion_radio/ui-new/HomeScreen.h | 1759 +++++++++++++ examples/companion_radio/ui-new/PerfL1.h | 138 + .../companion_radio/ui-new/SplashScreen.h | 98 + examples/companion_radio/ui-new/UITask.cpp | 2218 +---------------- 7 files changed, 2232 insertions(+), 2217 deletions(-) create mode 100644 examples/companion_radio/ui-new/CardKB.h create mode 100644 examples/companion_radio/ui-new/HomeScreen.h create mode 100644 examples/companion_radio/ui-new/PerfL1.h create mode 100644 examples/companion_radio/ui-new/SplashScreen.h diff --git a/docs/solo/developer/ui-framework.md b/docs/solo/developer/ui-framework.md index 0b340118..d4a4b26b 100644 --- a/docs/solo/developer/ui-framework.md +++ b/docs/solo/developer/ui-framework.md @@ -65,7 +65,7 @@ public: 4. Reach it from somewhere — usually a row in `ToolsScreen.h` (add an `Action` enum value, a row in the right section table, and a `run()` case). The Tools home page lists the last two tools used; give the new one a state - in `HomeScreen::toolState()` (`UITask.cpp`) if it has one worth showing. + in `HomeScreen::toolState()` (`HomeScreen.h`) if it has one worth showing. That's the whole contract. Steps 1, 3 and 4 are compiler-checked (a mismatch won't link); only a forgotten step 2 can slip through — every screen pointer is @@ -296,7 +296,7 @@ Draw with `miniIconDraw(display, x, topY, ICON_FOO)` (auto-scaled & centered), (centred in a status-bar slot). Use icons sparingly: headers and the Tools list have none, the Home page row is the main place for them. -The home status bar (`HomeScreen::renderBatteryIndicator()`, `UITask.cpp`) +The home status bar (`HomeScreen::renderBatteryIndicator()`, `HomeScreen.h`) draws plain glyphs right to left from a fixed, priority-ordered table, with no boxes and no blinking: each icon tells a state by its shape (Bluetooth only while a phone is connected, the GPS reticle broken until a fix, a pin while diff --git a/examples/companion_radio/ui-core/Lettering.h b/examples/companion_radio/ui-core/Lettering.h index 84812080..e1570ae7 100644 --- a/examples/companion_radio/ui-core/Lettering.h +++ b/examples/companion_radio/ui-core/Lettering.h @@ -1,6 +1,6 @@ #pragma once // The MeshCore wordmark and its lettering, shared by both boot splashes -// (ui-new/UITask.cpp on the OLED/e-ink boards, ui-lvgl/Splash.h on the L2) +// (ui-new/SplashScreen.h on the OLED/e-ink boards, ui-lvgl/Splash.h on the L2) // and ui-new's big clock (Home clock page, lock screen): // the 128x13 wordmark bitmap, its own letters, the extra characters drawn in // the same strokes (digits, '.', ':', '-', v, l, d, space), and the Solo version / diff --git a/examples/companion_radio/ui-new/CardKB.h b/examples/companion_radio/ui-new/CardKB.h new file mode 100644 index 00000000..1f369339 --- /dev/null +++ b/examples/companion_radio/ui-new/CardKB.h @@ -0,0 +1,230 @@ +#pragma once +// Included into UITask.cpp only: the CardKB (I2C keyboard) side of UITask. + +// The CardKB background poll task (see UITask::cardkbTask) shares its I2C bus +// with the environment sensors, and some screens query those sensors while +// rendering -- exactly when that task is active. Holding this lock around a +// sensor query makes the two take turns. No-op on builds without the task. +#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) +static void* g_cardkb_bus_mutex = nullptr; // SemaphoreHandle_t, set by startCardKBCapture() +struct CardKBBusLock { + CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreTake((SemaphoreHandle_t)g_cardkb_bus_mutex, portMAX_DELAY); } + ~CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreGive((SemaphoreHandle_t)g_cardkb_bus_mutex); } +}; +#else +struct CardKBBusLock { }; +#endif + +#if defined(CARDKB_I2C) +// CardKB's "fn" column (key_map in M5Stack's unit_CardKB.cpp): Fn+ sends 0x80 + that key's row index, entirely disjoint from every other +// code this UI recognises. Indexed by (raw - 0x80); non-letter slots (digits, +// arrows, enter, tab, bs, space -- handled separately or unused) are 0. +static const char CARDKB_FN_BASE[48] = { + 0,0,0,0,0,0,0,0,0,0,0,0,0, // esc,1-0,bs,tab + 'q','w','e','r','t','y','u','i','o','p', 0, 0,0, // q-p, (unused), LEFT,UP + 'a','s','d','f','g','h','j','k','l', 0, 0,0, // a-l, enter, DOWN,RIGHT + 'z','x','c','v','b','n','m', 0,0,0, // z-m, comma,period,space +}; +#endif + +// Poll an optional CardKB (I2C keyboard, addr 0x5F) on CARDKB_I2C, feeding +// the same key queue as every physical button. Most of its output needs no +// translation at all: CardKB's own arrow/Enter/Esc byte codes are already +// identical to this UI's KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL (0xB4-0xB7, 13, +// 27), and Backspace (0x08) / printable ASCII (0x20-0x7E) collide with +// nothing that existed before. Plain Enter/arrows act like the physical +// centre button/joystick (grid commit/navigate) -- except in Compact mode's +// plain grid state (see below), which is designed to need no joystick at all. +// Tab (0x09, otherwise unused) is the Hold-Enter equivalent everywhere, +// including the ~30 non-keyboard Hold-Enter menus (message reply/navigate, +// Bot/Admin/Repeater, ...) and inside the on-screen keyboard itself (shift- +// lock, clear-all, accent popup on whatever cell is selected) -- it used to +// need a separate Fn+Tab for the latter, but that was pure redundancy: plain +// Tab already covered every case Fn+Tab did, just not while the keyboard was +// showing, so the carve-out was dropped instead of the shortcut. In Compact +// mode's plain grid state Tab means something more useful instead (opens the +// placeholder picker directly -- see below). Fn still gives two other clean, +// stateless modifiers: +// - Fn+Enter (0xA3) submits the field (KEY_KB_ENTER) without needing to +// navigate to the special row's DONE cell. The placeholder/accent popups +// are modal and consume it first (dismiss them with Enter/Esc), same as +// they consume every other key. +// - Fn+ opens the accent popup for that base letter directly +// (KeyboardWidget::openAccentFor()) -- no arrow-hunting across the grid. +// CardKB is level-triggered (it keeps returning the held key's byte, not just +// once), so _cardkb_last_raw debounces it into one press per physical +// keypress, same as a MomentaryButton's CLICK event. +void UITask::pollCardKB() { +#if defined(CARDKB_I2C) + if (!_has_cardkb) return; + // No artificial throttle: unlike a MomentaryButton (BUTTON_USE_INTERRUPTS + // latches every edge in an ISR ring buffer, so it survives a blocking e-ink + // refresh untouched), CardKB is plain I2C polling with no interrupt line on + // the Grove cable and no onboard queue -- it only ever reports "what's held + // right now". A press that starts and fully releases while curr->render() + // is blocked is physically unobservable, no software fix can recover it. + // Polling every loop() iteration (same as a digital button's check(), which + // has no throttle either) just shrinks that miss window down to exactly the + // render() duration instead of render()+30ms. + // First, keys the background task captured while the display blocked, in order. + while (_cardkb_q_tail != _cardkb_q_head) { + uint8_t q = _cardkb_q[_cardkb_q_tail]; + _cardkb_q_tail = (uint8_t)((_cardkb_q_tail + 1) % CARDKB_Q_LEN); + handleCardKBByte(q); + } + uint8_t raw; + if (!readCardKBEdge(raw)) return; + handleCardKBByte(raw); +#endif +} + +#if defined(CARDKB_I2C) +bool UITask::readCardKBEdge(uint8_t& raw) { + CARDKB_I2C.requestFrom(0x5F, 1); + if (!CARDKB_I2C.available()) return false; + raw = CARDKB_I2C.read(); + if (raw == _cardkb_last_raw) return false; // still held (or still released) -- no new edge + _cardkb_last_raw = raw; + return raw != 0; // 0 = key just released, nothing to enqueue +} + +// Single producer (the poll task) / single consumer (pollCardKB() in the +// loop) ring -- byte-sized indices, so no locking needed. +void UITask::cardkbQueuePush(uint8_t raw) { + uint8_t next = (uint8_t)((_cardkb_q_head + 1) % CARDKB_Q_LEN); + if (next == _cardkb_q_tail) return; // full: drop (16 keys within one refresh) + _cardkb_q[_cardkb_q_head] = raw; + _cardkb_q_head = next; +} + +#if defined(NRF52_PLATFORM) +void UITask::cardkbTask(void* self) { + UITask* t = (UITask*)self; + SemaphoreHandle_t m = (SemaphoreHandle_t)t->_cardkb_mutex; + for (;;) { + if (t->_cardkb_bg_active && xSemaphoreTake(m, portMAX_DELAY) == pdTRUE) { + if (t->_cardkb_bg_active) { // re-check: loop may have just finished + uint8_t raw; + if (t->readCardKBEdge(raw)) t->cardkbQueuePush(raw); + } + xSemaphoreGive(m); + } + vTaskDelay(ms2tick(10)); // a keypress lasts ~50+ ms, so 10 ms never misses one + } +} +#endif +#endif + +// Keep sampling the CardKB while the display blocks the loop, so keys typed +// during a (long, on big panels) refresh aren't lost. Called LAST in begin(): +// the heap is tight on large-panel builds, and every screen is allocated with +// `new` -- a failed allocation leaves that screen silently unopenable. Creating +// the optional poll task afterwards means a short heap costs the task (the +// CardKB is then only read between frames, as without it), never a screen. +void UITask::startCardKBCapture() { +#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) + if (!_has_cardkb) return; + SemaphoreHandle_t m = xSemaphoreCreateMutex(); + if (!m) return; + // Publish the mutex BEFORE creating the task: at higher priority it starts + // running inside xTaskCreate() and reads _cardkb_mutex straight away. + _cardkb_mutex = (void*)m; + // TASK_PRIO_NORMAL (2) preempts the loop task (TASK_PRIO_LOW, 1); BLE runs higher. + if (xTaskCreate(&UITask::cardkbTask, "cardkb", 512, this, TASK_PRIO_NORMAL, NULL) == pdPASS) { + g_cardkb_bus_mutex = m; // shared with CardKBBusLock (sensor queries) + return; + } + _cardkb_mutex = nullptr; // no task -> setCardKBBackground() stays a no-op + vSemaphoreDelete(m); +#endif +} + +// Hand CardKB sampling to the background task (on = loop is about to block on +// the display) or take it back (off). Taking the mutex on the way out waits for +// any in-flight background I2C read to finish before the loop touches the bus. +void UITask::setCardKBBackground(bool on) { +#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) + if (!_cardkb_mutex) return; + if (on) { + _cardkb_bg_active = true; + } else { + SemaphoreHandle_t m = (SemaphoreHandle_t)_cardkb_mutex; + xSemaphoreTake(m, portMAX_DELAY); + _cardkb_bg_active = false; + xSemaphoreGive(m); + } +#else + (void)on; +#endif +} + +void UITask::handleCardKBByte(uint8_t raw) { +#if defined(CARDKB_I2C) + + // Compact mode (Settings > Keyboard's "Ext. KB" row) hides the letter grid + // entirely, and is meant to guarantee joystick-free operation: while it's + // the active surface (KeyboardWidget::inPlainGridState() -- showing, no + // popup open, not already mid cursor-move) arrows drive the text cursor + // directly instead of a grid selection nobody could see anyway, and plain + // Tab opens the placeholder picker directly instead of the row/col-dependent + // Hold-Enter dispatch (which would be meaningless here -- row/col are never + // deliberately navigated to in this mode). Cursor mode / the accent / + // placeholder popups all render their own visible feedback regardless of + // Compact, so none of this applies once inPlainGridState() is false -- + // arrows/Tab fall through to their normal meaning there (e.g. arrows drive + // the placeholder/accent popup's own selection). + bool compact_grid = _node_prefs && _node_prefs->keyboard_cardkb_compact && _kb.inPlainGridState(); + + char key; + if (raw == 0xA3) { // Fn+Enter -- submit the field + key = KEY_KB_ENTER; + } else if (compact_grid && (raw == (uint8_t)KEY_LEFT || raw == (uint8_t)KEY_UP || + raw == (uint8_t)KEY_DOWN || raw == (uint8_t)KEY_RIGHT)) { + char woke = checkDisplayOn((char)raw); // already sets _next_refresh=0 when the display was on + if (woke && !_locked) _kb.moveCursorDirect((char)raw); + return; + } else if (raw == 0x09) { // Tab -- Hold-Enter equivalent, always (single shortcut: there used to + if (compact_grid) { // also be a separate Fn+Tab for this, but plain Tab already covers every + char woke = checkDisplayOn((char)raw); // case Fn+Tab did -- outside the keyboard, and now inside it + if (woke && !_locked) _kb.openPlaceholders(); // too -- so the modifier was pure redundancy) + return; + } + key = KEY_CONTEXT_MENU; + } else if (raw == 0x80) { + // Fn+Esc -- CardKB's lock/unlock gesture: a single press toggles _locked + // directly (unlike the physical Hold-Back+3xEnter combo's 3-press + // sequence), so it works to unlock a locked device too, where every + // other CardKB key is correctly discarded (see the Fn+ branch + // below). Esc, not the adjacent Fn+Backspace, on purpose: Fn and + // Backspace sit right next to each other on CardKB's layout, making that + // combo too easy to hit by accident; Esc is on the opposite side of the + // keyboard. One press is enough -- Fn+Esc is already a deliberate + // two-key combo, so it doesn't need the physical combo's extra 3x + // repetition to guard against accidental triggering. + if (_display && !_display->isOn()) _display->turnOn(); + toggleLock(); + return; + } else if (raw >= 0x80 && raw <= 0xAF) { // Fn+ -- open its accent popup + char base = CARDKB_FN_BASE[raw - 0x80]; + if (base == 0) return; // Fn+digit/symbol/arrow -- not used by this UI + char woke = checkDisplayOn(base); + // Every other key here goes through enqueueKey(), so it's naturally eaten + // while locked (see the dequeue-time "if (!_locked && curr)" gate in + // loop()). This path calls into the keyboard widget directly instead, so + // it needs its own _locked check -- otherwise a stray Fn+letter (e.g. the + // keyboard was left open before the device locked, or brushed against in + // a pocket) could pop the accent popup while the screen is supposed to + // ignore all input. + if (woke && !_locked) _kb.openAccentFor(base); + return; + } else { + // Plain Enter would otherwise commit whatever grid cell row/col happen to + // be frozen at (there's no grid navigation to have deliberately landed on + // one in Compact) -- submit instead, same as Fn+Enter. Backspace/ASCII + // passthrough is unaffected by Compact either way. + key = (compact_grid && raw == (uint8_t)KEY_ENTER) ? KEY_KB_ENTER : (char)raw; + } + enqueueKey(checkDisplayOn(key)); +#endif +} diff --git a/examples/companion_radio/ui-new/HomeScreen.h b/examples/companion_radio/ui-new/HomeScreen.h new file mode 100644 index 00000000..8d60101f --- /dev/null +++ b/examples/companion_radio/ui-new/HomeScreen.h @@ -0,0 +1,1759 @@ +#pragma once +// Included into UITask.cpp only (see the note on screen fragments there). +// Home: the clock, favourites, status, Bluetooth ... pages, and the lock screen. + +// The time on a tall portrait panel (e-ink in portrait -- height > width): HH +// and MM stacked on two lines in the huge built-in font (size 4, ~56 px tall) +// so the digits fill the narrow width. Returns the y just below it. +static int drawClockTall(DisplayDriver& d, int top_y, const struct tm* ti, bool h12) { + int hh = ti->tm_hour; + const char* ap = nullptr; + if (h12) { ap = (hh < 12) ? "AM" : "PM"; hh %= 12; if (hh == 0) hh = 12; } + const int cx = d.width() / 2; + char hbuf[4], mbuf[4]; + snprintf(hbuf, sizeof(hbuf), "%02d", hh); + snprintf(mbuf, sizeof(mbuf), "%02d", ti->tm_min); + + int y = top_y; + d.setTextSize(4); + const int lhb = d.getLineHeight(); + // The built-in GFX font advances 6 px per char but the glyph is only 5 px + // wide, so getTextWidth() over-reports by one trailing blank column and + // drawTextCentered() would bias the digits ~half a column to the left. + // Centre on the visible width (minus that trailing column) instead. + const int trail = d.getCharWidth() / 6; // one built-in column at this size + auto drawBig = [&](const char* s, int yy) { + d.setCursor(cx - ((int)d.getTextWidth(s) - trail) / 2, yy); + d.print(s); + }; + drawBig(hbuf, y); y += lhb + 2; + drawBig(mbuf, y); y += lhb + 2; + if (ap) { + d.setTextSize(2); + d.drawTextCentered(cx, y, ap); + y += d.getLineHeight() + 1; + } + d.setTextSize(1); + return y; +} + +// The big clock on wide panels (Home clock page, lock screen): HH:MM in the +// splash's slanted lettering (ui-core/Lettering.h) at `sc`, centred on cx. A +// 12-hour clock drops the leading zero, closes the gaps up and sets AM / PM +// beside the digits on their baseline -- "12:59 PM" still fits 128 px. +struct BigClock { int sc, h; }; +static BigClock bigClockSize(const DisplayDriver& d) { + int sc = d.width() / 64; + if (sc > d.height() / 32) sc = d.height() / 32; + if (sc < 1) sc = 1; + return { sc, lettering::LOGO_H * sc }; +} +// The big clock's text, gaps and AM / PM, measured: its full width (w), the +// digits' (dw). draw() puts its left edge at x. +struct BigClockText { + char t[8]; + const char* ap = nullptr; + int sc, gap, dw, w; + BigClockText(DisplayDriver& d, const struct tm* ti, bool h12, int scale) : sc(scale), gap(scale + 1) { + if (h12) { + int hh = ti->tm_hour % 12; if (hh == 0) hh = 12; + snprintf(t, sizeof(t), "%d:%02d", hh, ti->tm_min); + ap = ti->tm_hour < 12 ? "AM" : "PM"; + gap = sc / 2; + } else { + snprintf(t, sizeof(t), "%02d:%02d", ti->tm_hour, ti->tm_min); + } + dw = lettering::textW(t, 0) * sc + gap * ((int)strlen(t) - 1); + d.setTextSize(1); + w = dw + (ap ? (int)d.getTextWidth(ap) + 2 * sc : 0); + } + void draw(DisplayDriver& d, int x, int y) const { + info::lettering(d, x, y, t, sc, gap); + if (!ap) return; + d.setCursor(x + dw + 2 * sc, y + lettering::LOGO_H * sc - d.getLineHeight() + 1); + d.print(ap); + } +}; +static void drawBigClock(DisplayDriver& d, int cx, int y, const struct tm* ti, bool h12, int sc) { + BigClockText bt(d, ti, h12, sc); + bt.draw(d, cx - bt.w / 2, y); +} + +static const char* const WDAY[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; +static const char* const MON[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; + +// ── HomeScreen ──────────────────────────────────────────────────────────────── + +// A dashboard field in this display's font (ui-core/Telemetry.h); `nouns` +// when it stands without its label (the lock screen). "" for none. +static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv, uint16_t low_batt_mv, + int unread, bool unread_overflow, bool imperial, CayenneLPP* lpp, bool nouns) { + val[0] = '\0'; + if (field == telemetry::NONE || field >= telemetry::COUNT) return; + telemetry::Style st = L1_TELEMETRY; + st.nouns = nouns; + if (nouns) st.gps_dp = 2; // no label: room for the position is shorter + telemetry::Inputs in; + in.batt_mv = batt_mv; + in.low_batt_mv = low_batt_mv; + in.nodes = the_mesh.getNumContacts(); + in.unread = unread; + in.unread_more = unread_overflow; + in.imperial = imperial; +#if ENV_INCLUDE_GPS == 1 + in.loc = sensors.getLocationProvider(); + { NodePrefs* np = the_mesh.getNodePrefs(); in.gps_on = np && np->gps_enabled; } // off: no stale satellite count +#endif + if (telemetry::isSensor(field)) { + if (!lpp) { + static CayenneLPP s_lpp(200); s_lpp.reset(); + { CardKBBusLock bus_lock; sensors.querySensors(0xFF, s_lpp); } + lpp = &s_lpp; + } + in.lpp = lpp->getBuffer(); + in.lpp_len = lpp->getSize(); + } + telemetry::text(field, in, st, val, val_len); +} + + +class HomeScreen : public UIScreen { + enum HomePage { + CLOCK, + FAVOURITES, + STATUS, // radio, GPS, power, mesh at a glance (took over Radio / GPS / Sensors) + BLUETOOTH, + ADVERT, + SETTINGS, + MAP, + TOOLS, + QUICK_MSG, + SHUTDOWN, + Count, // keep before LOCK — navigable-page count + LOCK // lock screen: full clock + dashboard view, not a navigable page + }; + + // Selected slot on the Favourites page (0..FAVOURITES_COUNT - 1). + uint8_t _fav_sel = 0; + + // Slot payload (pubkey prefix, or channel index in byte 0 — see the slot's + // kind), or nullptr when the slot is empty. + const uint8_t* favSlotPrefix(int slot) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p || _task->isFavouriteSlotEmpty(slot)) return nullptr; + return p->favourite_contacts[slot]; + } + + // Unpin/Replace menu for a filled tile. Choosing what to pin is the Messages + // screen's job (see UITask::pickFavouriteTarget) -- the dial doesn't carry a + // second browser for contacts, rooms and channels. + PopupMenu _tile_menu; + int _pin_target_slot = -1; + + UITask* _task; + mesh::RTCClock* _rtc; + SensorManager* _sensors; + NodePrefs* _node_prefs; + uint8_t _page; + uint8_t _prev_page; // home page restored when the device unlocks + bool _shutdown_init; + + // Sideways slide between pages (OLED; drivers that can't read their frame + // back just cut over). _slide_dir is +1 (new page from the right), -1, or 0. + static const unsigned long SLIDE_MS = 220; + int8_t _slide_dir = 0; + uint8_t _slide_from = 0; + unsigned long _slide_t0 = 0; + + // First row under the header and the page-icon row (see render()). + static int contentTop(DisplayDriver& d) { + const int pg_half = (PAGE_ICON_PX * statusIconScale(d) + 1) / 2; + return statusBarH(d) + 2 * pg_half + 4; + } + // 0..1 eased progress of the running slide, or -1 when none. + float slideProgress() { + if (!_slide_dir) return -1; + unsigned long el = millis() - _slide_t0; + if (el >= SLIDE_MS) { _slide_dir = 0; return -1; } + float t = 1.0f - (float)el / SLIDE_MS; + return 1.0f - t * t * t; // ease-out: quick start, soft landing + } + void turnPage(int dir) { + const uint8_t from = _page; + _page = navPage(_page, dir); + if (_page == from) return; + _quick_sel = 0; // a quick panel starts on its first row + DisplayDriver* d = _task->getDisplay(); + // Pages with the header slide below it; the full-screen clock slides whole. + const bool whole = from == CLOCK || _page == CLOCK; + if (d && d->slideBegin(whole ? 0 : contentTop(*d))) { + _slide_dir = (int8_t)dir; _slide_from = from; _slide_t0 = millis(); + } + } + + unsigned long _advert_sent_ms = 0; // last advert sent from this page (0: none yet) + + // ── Quick panels: an icon page as a few rows with margins round + // them, the last one opening the full screen. Up/Down picks a row, Enter + // acts on it. The rows are the Settings list's own (value, switch, bar). + enum QKind : uint8_t { Q_VALUE, Q_SWITCH, Q_BAR, Q_BADGE, Q_MORE }; + enum QAct : uint8_t { QA_NONE, QA_MORE, QA_BRIGHTNESS, QA_GPS, QA_CHAT, QA_BT, + QA_ADVERT, QA_AUTO_ADVERT, QA_TOOL, QA_LOCK, QA_HIBERNATE }; + struct QRow { + const char* label; + char value[16]; + QKind kind; + QAct act; + uint8_t n; // switch on, bar level, unread count, QA_TOOL's action + bool overflow; // unread past what the badge can count + bool is_ch; // QA_CHAT: a channel (ch) or a DM / room (prefix) + uint8_t ch; + uint8_t prefix[4]; + char name[24]; // QA_CHAT: the label's storage + }; + static const int QUICK_MAX = 8; + // How many rows a quick panel has room for (at least the 3 a 128x64 OLED + // fits): a taller screen lists more recent chats. + int quickFit() const { + DisplayDriver* d = _task->getDisplay(); + if (!d) return 3; + const int n = (d->height() - contentTop(*d) - 5) / (d->lineStep() + 1); + return n < 3 ? 3 : n > QUICK_MAX ? QUICK_MAX : n; + } + uint8_t _quick_sel = 0; + bool _quick_edit = false; // the selected row is being changed with Left/Right + bool _quick_dirty = false; // ...and has moved: saved once when it ends + static bool isQuickPage(int p) { + return p == SETTINGS || p == QUICK_MSG || p == BLUETOOTH || p == ADVERT || p == TOOLS || p == SHUTDOWN; + } + + // What a tool is doing now, for its row on the Tools panel ("" when it has + // nothing running worth telling). + void toolState(ToolsScreen::Action a, char* buf, int n) { + buf[0] = 0; + switch (a) { + case ToolsScreen::ACT_TRAIL: { + TrailStore& tr = _task->trail(); + if (tr.isActive()) geo::fmtDist(buf, n, tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true); + else strncpy(buf, "Off", n); + break; + } + case ToolsScreen::ACT_LIVESHARE: + strncpy(buf, _node_prefs && _node_prefs->loc_share_enabled ? "On" : "Off", n); break; + case ToolsScreen::ACT_LOCATOR: + strncpy(buf, _node_prefs && _node_prefs->locator_enabled ? "On" : "Off", n); break; + case ToolsScreen::ACT_REPEATER: + strncpy(buf, _node_prefs && _node_prefs->client_repeat ? "On" : "Off", n); break; + case ToolsScreen::ACT_AUTOADVERT: + strncpy(buf, AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::indexOf( + _node_prefs ? _node_prefs->advert_auto_interval_sec : 0)], n); break; + case ToolsScreen::ACT_CLOCK: + if (_task->isTimerRunning()) { + const uint32_t s = (_task->timerRemainingMs() + 999) / 1000; + if (s >= 3600) snprintf(buf, n, "%lu:%02lu:%02lu", (unsigned long)(s / 3600), (unsigned long)(s / 60 % 60), (unsigned long)(s % 60)); + else snprintf(buf, n, "%lu:%02lu", (unsigned long)(s / 60), (unsigned long)(s % 60)); + } + break; + default: break; + } + buf[n - 1] = 0; + } + + // The `max` newest conversations, DM / room or channel, newest first. + int recentChats(QRow* out, int max) { + MessageHistory& h = _task->core().history; + struct Cand { bool is_ch; uint8_t ch; uint8_t prefix[4]; uint32_t ts; } c[2 * QUICK_MAX]; + if (max > QUICK_MAX) max = QUICK_MAX; + int nc = 0; + for (int j = 0; j < h.dmHistCount() && nc < max; j++) { + const DmHistEntry& e = h.dmAtPos(h.dmHistPosNewest(j)); + bool seen = false; + for (int k = 0; k < nc; k++) if (memcmp(c[k].prefix, e.prefix, 4) == 0) seen = true; + if (seen) continue; + c[nc].is_ch = false; c[nc].ch = 0; memcpy(c[nc].prefix, e.prefix, 4); c[nc].ts = e.timestamp; nc++; + } + const int nd = nc; + for (int j = 0; j < h.chHistCount() && nc < nd + max; j++) { + const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j)); + bool seen = false; + for (int k = nd; k < nc; k++) if (c[k].ch == e.ch_idx) seen = true; + if (seen) continue; + c[nc].is_ch = true; c[nc].ch = e.ch_idx; c[nc].ts = e.timestamp; nc++; + } + for (int a = 0; a < nc; a++) // newest first + for (int b = a + 1; b < nc; b++) + if (c[b].ts > c[a].ts) { Cand t = c[a]; c[a] = c[b]; c[b] = t; } + const uint32_t now = rtc_clock.getCurrentTime(); + int n = 0; + for (int k = 0; k < nc && n < max; k++) { + QRow& r = out[n]; + memset(&r, 0, sizeof(r)); + r.act = QA_CHAT; r.is_ch = c[k].is_ch; + if (r.is_ch) { + ChannelDetails cd; + if (!the_mesh.getChannel(c[k].ch, cd) || !cd.name[0]) continue; + r.ch = c[k].ch; + snprintf(r.name, sizeof(r.name), "#%s", chanctl::bareName(cd.name)); + r.n = _task->getChannelUnread(r.ch); + r.overflow = r.n > 0 && _task->getChannelUnreadOverflow(r.ch); + } else { + ContactInfo ci; + if (!MessageHistory::contactByPrefix(c[k].prefix, ci)) continue; + memcpy(r.prefix, c[k].prefix, 4); + snprintf(r.name, sizeof(r.name), "%s", ci.name); + r.n = _task->getDMUnread(ci.id.pub_key); + r.overflow = r.n > 0 && _task->getDMUnreadOverflow(ci.id.pub_key); + } + r.label = r.name; + if (r.n > 0) r.kind = Q_BADGE; + else { r.kind = Q_VALUE; geo::fmtAgeShort(r.value, sizeof(r.value), now, c[k].ts); } + n++; + } + return n; + } + + int buildQuick(int page, QRow* rows) { + int n = 0; + auto add = [&](const char* label, QKind k, QAct a, uint8_t v) { + QRow& r = rows[n++]; + memset(&r, 0, sizeof(r)); + r.label = label; r.kind = k; r.act = a; r.n = v; + }; + // A panel with a whole screen behind it lists the way in first, its + // shortcuts after. + if (page == SETTINGS) { + add("All settings", Q_MORE, QA_MORE, 0); +#if FEAT_BRIGHTNESS_SETTING + add("Brightness", Q_BAR, QA_BRIGHTNESS, _node_prefs ? _node_prefs->display_brightness + 1 : 0); +#endif +#if ENV_INCLUDE_GPS == 1 + if (sensors.getLocationProvider()) add("GPS", Q_SWITCH, QA_GPS, _task->getGPSState() ? 1 : 0); +#endif + } else if (page == QUICK_MSG) { + add("All messages", Q_MORE, QA_MORE, 0); + n += recentChats(rows + n, quickFit() - 1); + } else if (page == BLUETOOTH) { + const bool on = _task->isSerialEnabled(); + add("Bluetooth", Q_SWITCH, QA_BT, on ? 1 : 0); + add("Phone", Q_VALUE, QA_NONE, 0); + char* v = rows[n - 1].value; + if (!on) strcpy(v, "-"); + else if (_task->isBLEConnected()) strcpy(v, "Connected"); + // The pairing PIN is BLE-specific: show it while BLE is on but not yet + // bonded. (Gating on a plain isConnected() broke this on dual builds, + // where it's hardcoded true.) + else if (the_mesh.getBLEPin() != 0) snprintf(v, sizeof(rows[0].value), "PIN %d", (int)the_mesh.getBLEPin()); + else strcpy(v, "Waiting"); + } else if (page == ADVERT) { + add("Send now", Q_VALUE, QA_ADVERT, 0); + if (_advert_sent_ms) { // when this page last sent one + const unsigned long ago = (millis() - _advert_sent_ms) / 60000UL; + char* v = rows[n - 1].value; + if (ago == 0) strcpy(v, "sent"); + else if (ago < 60) snprintf(v, sizeof(rows[0].value), "%lum ago", ago); + else snprintf(v, sizeof(rows[0].value), "%luh ago", ago / 60); + } + add("Auto", Q_VALUE, QA_AUTO_ADVERT, 0); + strcpy(rows[n - 1].value, AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::indexOf( + _node_prefs ? _node_prefs->advert_auto_interval_sec : 0)]); + } else if (page == TOOLS) { + add("All tools", Q_MORE, QA_MORE, 0); + for (int i = 0; i < ToolsScreen::RECENT_MAX; i++) { + const ToolsScreen::Action a = ToolsScreen::s_recent[i]; + add(ToolsScreen::labelOf(a), Q_VALUE, QA_TOOL, (uint8_t)a); + toolState(a, rows[n - 1].value, sizeof(rows[0].value)); + } + } else if (page == SHUTDOWN) { + add("Lock screen", Q_VALUE, QA_LOCK, 0); + add("Hibernate", Q_VALUE, QA_HIBERNATE, 0); + // The charge left, which is what you weigh before switching off. + const int mv = _task->getBattMilliVolts(); + snprintf(rows[n - 1].value, sizeof(rows[0].value), "%d%%%s", + battery::percent(mv, _node_prefs ? (int)_node_prefs->low_batt_mv : 0), + board.isExternalPowered() ? " USB" : ""); + } + return n; + } + + // Small arrows either side of x0..x0+w while Left/Right change a row. + static void drawEditArrows(DisplayDriver& d, int x0, int w, int cy, int s) { + for (int i = 0; i < 3; i++) { + d.fillRect(x0 - 6 * s + i * s, cy - i * s, s, (2 * i + 1) * s); // ◂ + d.fillRect(x0 + w + 5 * s - i * s, cy - i * s, s, (2 * i + 1) * s); // ▸ + } + } + + // The rows, from a gap below the page icons and inset from both sides. + int drawQuickPanel(DisplayDriver& d, int top, QRow* rows, int n) { + const int W = d.width(), m = W / 16, lh = d.getLineHeight(), step = d.lineStep() + 1; + const int reserve = m - 2; // so a row's right edge is W - m + if (_quick_sel >= n) _quick_sel = n - 1; + int y = top + 5, mq = 0; + for (int i = 0; i < n; i++, y += step) { + const QRow& r = rows[i]; + const bool sel = i == _quick_sel; + d.setColor(DisplayDriver::LIGHT); + if (sel) { d.fillSoftRect(m - 3, y - 1, W - 2 * m + 6, lh + 1); d.setColor(DisplayDriver::DARK); } + switch (r.kind) { + case Q_SWITCH: info::switchRow(d, y, r.label, r.n != 0, sel, reserve, m); break; + case Q_BAR: { + d.setCursor(m, y); d.print(r.label); + const int box = lh - 3, gap = 2, s = miniIconScale(d), bars = 5 * box + 4 * gap; + const bool editing = sel && _quick_edit; + const int x0 = W - m - bars - (editing ? 6 * s : 0); + if (editing) drawEditArrows(d, x0, bars, y + 1 + box / 2, s); + for (int k = 0; k < 5; k++) { + const int bx = x0 + k * (box + gap); + if (k < r.n) d.fillRect(bx, y + 1, box, box); else d.drawRect(bx, y + 1, box, box); + } + break; + } + case Q_BADGE: { + const int bw = d.unreadBadgeWidth(r.n, r.overflow); + d.drawTextEllipsized(m, y, W - 2 * m - bw - 4, r.label); + d.drawUnreadBadge(W - m, y, r.n, sel, r.overflow); + break; + } + case Q_MORE: + d.setCursor(m, y); d.print(r.label); + d.drawTextRightAlign(W - m, y, ">"); + break; + default: + if (sel && _quick_edit) { // a value being changed: arrows round it + const int s = miniIconScale(d), vw = d.getTextWidth(r.value), x0 = W - m - vw - 6 * s; + d.setCursor(m, y); d.print(r.label); + d.setCursor(x0, y); d.print(r.value); + drawEditArrows(d, x0, vw, y + 1 + (lh - 3) / 2, s); + } else { + int q = info::valueRow(d, y, r.label, r.value, sel, reserve, m); + if (q > 0) mq = q; + } + } + d.setColor(DisplayDriver::LIGHT); + } + return mq; + } + + void quickAct(int page, const QRow& r) { + switch (r.act) { + case QA_NONE: break; + case QA_MORE: + if (page == SETTINGS) _task->gotoSettingsScreen(); + else if (page == TOOLS) _task->gotoToolsScreen(); + else _task->gotoMessagesScreen(); + break; + case QA_BRIGHTNESS: + case QA_AUTO_ADVERT: _quick_edit = true; break; // Left/Right change it, Enter / Back keep it + case QA_BT: + if (_task->isSerialEnabled()) _task->disableSerial(); else _task->enableSerial(); + break; + case QA_ADVERT: + _task->notify(UIEventType::ack); + if (the_mesh.advert()) _advert_sent_ms = millis(); // the row says so + else _task->showAlert("Advert failed", 1000); + break; + case QA_TOOL: ToolsScreen::run(_task, (ToolsScreen::Action)r.n, true); break; + case QA_LOCK: _task->lockScreen(); break; + case QA_HIBERNATE: _shutdown_init = true; break; // waits for the button to be released + case QA_GPS: _task->toggleGPS(false); break; + case QA_CHAT: + if (r.is_ch) { _task->openChannelHistory(r.ch); break; } + { + ContactInfo ci; + if (!MessageHistory::contactByPrefix(r.prefix, ci)) break; + if (ci.type == ADV_TYPE_ROOM) _task->openRoomServer(ci); else _task->openContactDM(ci); + } + break; + } + } + + int pageBit(int page) const { + if (page == CLOCK) return NodePrefs::HPB_CLOCK; + if (page == FAVOURITES) return NodePrefs::HPB_FAVOURITES; + if (page == STATUS) return NodePrefs::HPB_RADIO; // the old Radio slot + if (page == BLUETOOTH) return NodePrefs::HPB_BLUETOOTH; + if (page == ADVERT) return NodePrefs::HPB_ADVERT; + if (page == TOOLS) return NodePrefs::HPB_TOOLS; + if (page == SHUTDOWN) return NodePrefs::HPB_SHUTDOWN; + if (page == MAP) return NodePrefs::HPB_MAP; + return -1; // SETTINGS, QUICK_MSG always visible (no mask bit) + } + + // Maps page_order bit-index back to the HomePage enum value for this build. + // Returns -1 if the page is not compiled in. + int bitToPage(int bit) const { + switch (bit) { + case NodePrefs::HPB_CLOCK: return CLOCK; + case NodePrefs::HPB_FAVOURITES: return FAVOURITES; + case NodePrefs::HPB_RADIO: return STATUS; + case NodePrefs::HPB_BLUETOOTH: return BLUETOOTH; + case NodePrefs::HPB_ADVERT: return ADVERT; + case NodePrefs::HPB_TOOLS: return TOOLS; + case NodePrefs::HPB_SHUTDOWN: return SHUTDOWN; + case NodePrefs::HPB_SETTINGS: return SETTINGS; + case NodePrefs::HPB_QUICK_MSG: return QUICK_MSG; + case NodePrefs::HPB_MAP: return MAP; + default: return -1; + } + } + + bool isPageVisible(int page) const { + int bit = pageBit(page); + if (bit < 0) return true; + uint16_t mask = (_node_prefs && _node_prefs->home_pages_mask) ? _node_prefs->home_pages_mask : NodePrefs::HP_ALL; + // Status stands in for the Radio, GPS and Sensors pages: shown if any was. + if (page == STATUS) return (mask & (NodePrefs::HP_RADIO | NodePrefs::HP_GPS | NodePrefs::HP_SENSORS)) != 0; + return (mask >> bit) & 1; + } + + // Build ordered list of all visible pages, respecting page_order when set. + // Returns count; out[] receives HomePage enum values. + int buildVisibleOrder(int* out) const { + int n = 0; + bool custom = _node_prefs && _node_prefs->page_order_set == NodePrefs::PAGE_ORDER_MAGIC; + if (custom) { + for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) { + uint8_t v = _node_prefs->page_order[i]; + if (v < 1 || v > NodePrefs::HPB_COUNT) break; + int pg = bitToPage(v - 1); + if (pg >= 0 && pg < (int)Count && isPageVisible(pg)) out[n++] = pg; + } + // Append any visible page missing from page_order (handles corrupted/migrated prefs) + for (int pg = 0; pg < (int)Count; pg++) { + if (!isPageVisible(pg)) continue; + bool found = false; + for (int i = 0; i < n; i++) if (out[i] == pg) { found = true; break; } + if (!found) out[n++] = pg; + } + } else { + for (int pg = 0; pg < (int)Count; pg++) + if (isPageVisible(pg)) out[n++] = pg; + } + return n; + } + + int navPage(int from, int dir) const { + int order[(int)Count]; int n = buildVisibleOrder(order); + if (n == 0) return from; + int cur = 0; + for (int i = 0; i < n; i++) if (order[i] == from) { cur = i; break; } + return order[((cur + dir) % n + n) % n]; + } + + // reserve_left: how much width from x=0 must stay clear of status icons -- + // the time / node name on the pages (name_min below); the lock page passes + // 0, its clock sits under the bar. -1 = use the normal name reserve. + int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int reserve_left = -1) { + int low_mv = _node_prefs ? (int)_node_prefs->low_batt_mv : 0; + int pct = battery::percent((int)batteryMilliVolts, low_mv); + + uint8_t mode = battery::mode(_node_prefs ? _node_prefs->batt_display_mode : 0); + + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + const int lh = display.getLineHeight(); + const int cw = display.getCharWidth(); + const int sis = statusIconScale(display); // STATUS_ICON_SCALE, or the font's + const int bar_h = statusBarH(display); + const int ind = STATUS_ICONS_SCALED ? 7 * sis + 2 : cw + 2; // indicator slot width + const int ind_h = (bar_h > lh) ? bar_h - 2 : (display.isSingleFont() ? lh - 2 : lh); + const int text_y = (bar_h > lh) ? (bar_h - lh) / 2 : 0; // % / V text centred in a tall bar + const int ind_gap = display.pixelScale() > 1 ? 3 : 1; // gap between indicator boxes + + int battLeftX; + if (mode == battery::PERCENT) { + char buf[6]; + snprintf(buf, sizeof(buf),"%d%%", pct); + battLeftX = display.width() - display.getTextWidth(buf) - 1; + display.setCursor(battLeftX, text_y); + display.print(buf); + } else if (mode == battery::VOLTAGE) { + char buf[8]; + snprintf(buf, sizeof(buf),"%u.%02uV", batteryMilliVolts / 1000, (batteryMilliVolts % 1000) / 10); + battLeftX = display.width() - display.getTextWidth(buf) - 1; + display.setCursor(battLeftX, text_y); + display.print(buf); + } else { // icon — scales with lh, same box height as the status icons beside it (ind_h) + const int iconH = ind_h; + const int iconW = (bar_h > lh) ? ind_h * 2 : lh * 2; + const int bm = display.pixelScale() > 1 ? 3 : 2; // inner margin: 3px on a doubled panel, else 2px + battLeftX = display.width() - iconW - 3; + // The outline with its corner pixels left out, softly rounded. + display.fillRect(battLeftX + 1, 0, iconW - 2, 1); + display.fillRect(battLeftX + 1, iconH - 1, iconW - 2, 1); + display.fillRect(battLeftX, 1, 1, iconH - 2); + display.fillRect(battLeftX + iconW - 1, 1, 1, iconH - 2); + // Nub height/2, vertically centred by remaining-space/2 rather than a flat + // iconH/4 margin — the flat form only centres when iconH is a multiple of + // 4 (true for the old built-in font's lh=8, false for misc-fixed's 7/9), + // so it visibly drifted off-centre once the box height changed. + const int nub_h = iconH / 2; + display.fillRect(battLeftX + iconW, (iconH - nub_h) / 2, 2, nub_h); + int fillW = (pct * (iconW - 2 * bm)) / 100; + display.fillRect(battLeftX + bm, bm, fillW, iconH - 2 * bm); + } + + // Secondary status icons, laid out right→left in PRIORITY order so a crowded + // bar sheds its least-important cues instead of crushing the time. Once an + // icon won't fit above the reserved area, every lower-priority icon after + // it is dropped too (the list is ordered high→low). + // + // Priority: power > BT > GPS fix > alarm > mute > auto-advert > trail > live-share > + // repeater. Battery (drawn above) is always rightmost. The background modes + // (advert / trail / live-share / repeater) stay outside any BT gate — they + // keep running with Bluetooth off, so their cue must not vanish with it. + LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr; + bool gps_on = loc && _node_prefs && _node_prefs->gps_enabled; + bool mute_on = false; +#ifdef PIN_BUZZER + mute_on = _task->isBuzzerQuiet(); +#endif + // Plain glyphs, no blinking: each one shows a state by its shape. + // Bluetooth only once a phone is connected (it's on nearly always; the + // Bluetooth page tells the rest), GPS as a broken reticle until a fix. + const bool gps_fix = gps_on && loc->isValid(); + struct Sicon { bool active; const MiniIcon* icon; }; + const Sicon icons[] = { + { board.isExternalPowered(), &ICON_CHARGE }, // USB power: next to the battery + { _task->isSerialEnabled() && _task->isBLEConnected(), &ICON_BLUETOOTH }, + { gps_on, gps_fix ? &ICON_GPS : &ICON_GPS_SEARCH }, + { _node_prefs && _node_prefs->alarm_on, &ICON_ALARM }, + { mute_on, &ICON_MUTE }, + { _node_prefs && _node_prefs->advert_auto_interval_sec > 0, &ICON_ADVERT }, + { _task->trail().isActive(), &ICON_TRAIL }, + { _node_prefs && _node_prefs->loc_share_enabled, &ICON_MAP_CONTACT }, + { _node_prefs && _node_prefs->client_repeat, &ICON_REPEATER }, + }; + + int x = battLeftX; + const int name_min = (reserve_left >= 0) ? reserve_left : display.getCharWidth() * 5; + // Settings > Message alert Compact: the envelope and its count, first by the + // battery; its place is kept through the blink so nothing beside it jumps. + if (const int n = _task->msgBadgeCount()) { + const int ix = x - mailCountWidth(display, n) - ind_gap - 2 * miniIconScale(display); // the icons' boxes pad theirs + if (ix >= name_min) { + if (_task->msgBadgeLit()) drawMailCount(display, ix, 0, ind_h, n); + _task->msgBadgeInBar(); + x = ix; + } + } + for (const Sicon& s : icons) { + if (!s.active) continue; + int ix = x - ind - ind_gap; + if (ix < name_min) break; // out of room — drop this + all lower priority + if (STATUS_ICONS_SCALED) drawSlotIconScaled(display, ix, ind, ind_h, *s.icon, sis); + else drawSlotIcon(display, ix, ind, ind_h, *s.icon); + x = ix; + } + return x; + } + + CayenneLPP sensors_lpp; + int sensors_nb = 0; + unsigned long next_sensors_refresh = 0; // 0: read on the next call + + void refresh_sensors() { + if (!next_sensors_refresh || (int32_t)(millis() - next_sensors_refresh) >= 0) { + sensors_lpp.reset(); + sensors_nb = 0; + sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + { CardKBBusLock bus_lock; sensors.querySensors(0xFF, sensors_lpp); } + LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize()); + uint8_t channel, type; + while(reader.readHeader(channel, type)) { + reader.skipData(type); + sensors_nb ++; + } +#if AUTO_OFF_MILLIS > 0 + next_sensors_refresh = (millis() + 5000) | 1; // refresh sensor values every 5 sec +#else + next_sensors_refresh = (millis() + 60000) | 1; // refresh sensor values every 1 min +#endif + } + } + +public: + HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs) + : _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0), + _prev_page(0), _shutdown_init(false), sensors_lpp(200) { } + + // Flips home screen between LOCK page and the page that was showing before locking + void setLocked(bool locked) { + if (locked) { + if (_page != LOCK) _prev_page = _page; + _page = LOCK; + } else { + _page = _prev_page; + } + } + + void poll() override { + if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released + // _shutdown_init is never cleared elsewhere -- on real hardware + // that's harmless because _board->powerOff() halts the MCU, so + // there is no next poll() tick to matter. In the sim, powerOff() + // is a deliberate no-op (no real hardware to power off, see + // SimMainBoard.h), so without this the instance keeps running and + // this branch re-fires shutdown() -> _display->turnOff() on every + // single tick forever, repeatedly blacking out its canvas -- which + // fights with a freshly reset instance's own boot splash trying to + // render onto that same (simInstanceTag-keyed) canvas element. + _shutdown_init = false; + _task->shutdown(); + } + } + + // Compact map preview for the Home "Map" page: own position, the GPS trail, + // and live-tracked contacts (◆) folded into one auto-scaled box. A simplified + // cousin of TrailScreen's map (no grid/labels, no break markers) so the home + // carousel stays light. Returns false (and draws nothing) when there's + // nothing to show. + bool drawMapPreview(DisplayDriver& display, int ax, int ay, int aw, int ah) { + if (aw < 8 || ah < 8) return false; + bool init = false; + int32_t mnla = 0, mxla = 0, mnlo = 0, mxlo = 0; + auto fold = [&](int32_t la, int32_t lo) { + if (!init) { mnla = mxla = la; mnlo = mxlo = lo; init = true; } + else { if (la < mnla) mnla = la; if (la > mxla) mxla = la; + if (lo < mnlo) mnlo = lo; if (lo > mxlo) mxlo = lo; } + }; + TrailStore& tr = _task->trail(); + if (!tr.empty()) { int32_t a, b, c, d; tr.boundingBox(a, b, c, d); fold(a, b); fold(c, d); } + LiveTrackStore& lt = _task->liveTrack(); + uint32_t now = rtc_clock.getCurrentTime(); + for (int i = 0; i < LiveTrackStore::CAPACITY; i++) + if (lt.isActive(i, now)) fold(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6); + int32_t mla, mlo; + bool have_gps = _task->currentLocation(mla, mlo); + if (have_gps) fold(mla, mlo); + int32_t tla, tlo; + bool have_tgt = _task->activeTargetPos(tla, tlo); // active Locator/Nav target + if (have_tgt) fold(tla, tlo); + if (!init) return false; + + // North marker — top-right, the same mini-icon as the full Trail map. + display.setColor(DisplayDriver::LIGHT); + { + const int ns = miniIconScale(display); + miniIconDrawTop(display, ax + aw - ICON_MAP_NORTH.w * ns - 1, ay + 1, ICON_MAP_NORTH); + } + + int cx = ax + aw / 2, cy = ay + ah / 2; + // Degenerate: one coincident point — just centre the markers. + if (mnla == mxla && mnlo == mxlo) { + for (int i = 0; i < LiveTrackStore::CAPACITY; i++) + if (lt.isActive(i, now)) { miniIconDrawCentered(display, cx, cy, ICON_MAP_CONTACT); break; } + if (have_gps || !tr.empty()) miniIconDrawCentered(display, cx, cy, ICON_MAP_CURRENT); + if (have_tgt) miniIconDrawCentered(display, cx, cy, ICON_MAP_TARGET); // highlight on top + return true; + } + float avg_lat_rad = ((mnla + mxla) / 2.0e6f) * (float)M_PI / 180.0f; + float lon_scale = cosf(avg_lat_rad); if (lon_scale < 0.05f) lon_scale = 0.05f; + float lat_span = (float)(mxla - mnla); + float lon_span = (float)(mxlo - mnlo) * lon_scale; + float slat = (float)ah / (lat_span > 0 ? lat_span : 1.0f); + float slon = (float)aw / (lon_span > 0 ? lon_span : 1.0f); + float scale = (slat < slon) ? slat : slon; + int off_x = ax + (aw - (int)(lon_span * scale)) / 2; + int off_y = ay + (ah - (int)(lat_span * scale)) / 2; + auto project = [&](int32_t la, int32_t lo, int& px, int& py) { + px = off_x + (int)((float)(lo - mnlo) * lon_scale * scale); + py = off_y + (int)((float)(mxla - la) * scale); + }; + // Trail as a connected line, matching the full Trail map (shared helper — + // see gfx::drawTrail); no break marker here, just a silent gap. + gfx::drawTrail(display, tr, project, [](int, int, int, int) {}); + for (int i = 0; i < LiveTrackStore::CAPACITY; i++) { + if (!lt.isActive(i, now)) continue; + int px, py; project(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6, px, py); + miniIconDrawCentered(display, px, py, ICON_MAP_CONTACT); + } + if (have_gps) { int px, py; project(mla, mlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_CURRENT); } + // Active target flag drawn last so it stays legible even atop a contact/own dot. + if (have_tgt) { int px, py; project(tla, tlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_TARGET); } + + // Bottom-left scale reference, always shown — distance to the active + // target (or else the nearest live-tracked contact) now lives on the + // status line below instead (see statusDistanceKm() / render()), so this + // corner is free for it. + { + display.setColor(DisplayDriver::LIGHT); + int ty = ay + ah - display.getLineHeight(); + static const float M_PER_1E6 = 0.11132f; // metres per 1e-6° lat + float ppm = scale / M_PER_1E6; // pixels per metre + if (ppm > 0.0f) { + bool imp = _task->useImperial(); + static const float MET_M[] = { 5,10,25,50,100,250,500,1000,2000,5000,10000,25000,50000 }; + static const char* MET_L[] = { "5m","10m","25m","50m","100m","250m","500m","1km","2km","5km","10km","25km","50km" }; + static const float IMP_M[] = { 4.572f,15.24f,30.48f,76.2f,152.4f,402.34f,804.67f,1609.34f,4828.0f,16093.4f,80467.2f }; + static const char* IMP_L[] = { "15ft","50ft","100ft","250ft","500ft","1/4mi","1/2mi","1mi","3mi","10mi","50mi" }; + const float* M = imp ? IMP_M : MET_M; + const char* const* L = imp ? IMP_L : MET_L; + int N = imp ? (int)(sizeof(IMP_M) / sizeof(IMP_M[0])) : (int)(sizeof(MET_M) / sizeof(MET_M[0])); + float target = 8.0f / ppm; // short reference tick, not 1/3 of the width + int sel = 0; + for (int i = N - 1; i >= 0; i--) if (M[i] <= target) { sel = i; break; } + int barpx = (int)(M[sel] * ppm + 0.5f); + if (barpx < 5) barpx = 5; + if (barpx > aw / 4) barpx = aw / 4; + int bx = ax + 1, mid = ty + display.getLineHeight() / 2; + display.fillRect(bx, mid, barpx, 1); // single tick, on the text baseline + display.setCursor(bx + barpx + 2, ty); + display.print(L[sel]); + } + } + return true; + } + + // Distance shown on the MAP status line. The active Locator/Nav target + // takes priority — that's what the flag on the mini-map is pointing at, + // and it's the only way a waypoint target ever gets a distance readout + // here (a waypoint isn't a live-tracked contact). Falls back to the + // nearest live-tracked ([LOC]-sharing) contact when no target is set. + // -1 when we don't have a fix or nothing to measure against. + float statusDistanceKm() { + int32_t mla, mlo; + if (!_task->currentLocation(mla, mlo)) return -1.0f; + int32_t tla, tlo; + if (_task->activeTargetPos(tla, tlo)) return geo::haversineKm(mla, mlo, tla, tlo); + LiveTrackStore& lt = _task->liveTrack(); + uint32_t now = rtc_clock.getCurrentTime(); + float nearest_km = -1.0f; + for (int i = 0; i < LiveTrackStore::CAPACITY; i++) { + if (!lt.isActive(i, now)) continue; + float d = geo::haversineKm(mla, mlo, lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6); + if (nearest_km < 0.0f || d < nearest_km) nearest_km = d; + } + return nearest_km; + } + + // The Map page: the mini-map in a soft frame on the left, what it shows + // spelled out in a column on the right (GPS, the trail, live contacts or + // the target). Without a position the column still says what's missing. + // A tall screen (portrait e-ink) stacks them: the map, the four rows under it. + void drawMapPage(DisplayDriver& d, int top) { + const int W = d.width(), H = d.height(), lh = d.getLineHeight(); + const int s = miniIconScale(d); + const bool tall = H - top > W; + // A screen 40 characters wide (the 4.2"): a square map and a column of + // labelled rows beside it, one line apart. + const bool wide = !tall && W >= 40 * d.getCharWidth(); + const int rows_h = tall ? 4 * (lh + lh / 2) : 0; + const int ph = H - top - rows_h; + const int pw = tall ? W : wide ? (ph < W * 3 / 5 ? ph : W * 3 / 5) : W / 2; // map panel + d.setColor(DisplayDriver::LIGHT); + d.drawSoftRect(0, top, pw, ph); + if (!drawMapPreview(d, 2, top + 2, pw - 4, ph - 4)) { + // An empty sheet: a sparse dot grid with the reason over it. + for (int gy = top + 4; gy < top + ph - 2; gy += 6 * s) + for (int gx = 4; gx < pw - 2; gx += 6 * s) d.fillRect(gx, gy, s, s); + const char* why = "No fix"; + const int tw = d.getTextWidth(why), tx = (pw - tw) / 2, ty = top + (ph - lh) / 2; + d.setColor(DisplayDriver::DARK); + d.fillRect(tx - 2, ty - 1, tw + 4, lh + 2); + d.setColor(DisplayDriver::LIGHT); + d.setCursor(tx, ty); + d.print(why); + } + + if (wide) { drawMapColumn(d, pw + 6, top, W - 2); return; } + + // The column: four rows, always, like the Status tiles. + const int cx = tall ? 2 : pw + 3, cw = W - cx; + const int row_h = tall ? rows_h / 4 : ph / 4; + int y = (tall ? top + ph : top) + (row_h - lh) / 2 + 1; + auto line = [&](const MiniIcon& ic, const char* text) { + miniIconDraw(d, cx, y, ic); + const int tx = cx + ic.w * s + 2 * s; + d.drawTextEllipsized(tx, y, cx + cw - tx, text); + y += row_h; + }; + char buf[20]; +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + const bool on = _task->getGPSState(); + if (!loc) strcpy(buf, "No GPS"); + else if (!on) strcpy(buf, "Off"); + else if (!loc->isValid()) strcpy(buf, "No fix"); + else snprintf(buf, sizeof(buf), "%ld sats", loc->satellitesCount()); +#else + strcpy(buf, "No GPS"); +#endif + line(ICON_GPS, buf); + TrailStore& tr = _task->trail(); + if (tr.empty() && !tr.isActive()) strcpy(buf, "No trail"); + else geo::fmtDist(buf, sizeof(buf), tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true); + line(ICON_TRAIL, buf); + const float km = statusDistanceKm(); + int32_t tla, tlo; + const bool target = _task->activeTargetPos(tla, tlo); + const int live = _task->liveTrack().active(rtc_clock.getCurrentTime()); + if (live > 0 && km >= 0 && !target) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); + else snprintf(buf, sizeof(buf), "%d live", live); + line(ICON_MAP_CONTACT, buf); + if (target) { + if (km >= 0) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); + else strcpy(buf, "-"); + line(ICON_MAP_TARGET, buf); + } else { + snprintf(buf, sizeof(buf), "%d wpts", _task->waypoints().count()); + line(ICON_MAP_WAYPOINT, buf); + } + } + + // The wide Map page's column, x0..x1 from top: GPS and the position, the + // trail, then live contacts, the target and waypoints, each an icon, a + // label and the value at the right, the groups split by a dotted rule. + void drawMapColumn(DisplayDriver& d, int x0, int top, int x1) { + const int lh = d.getLineHeight(), s = miniIconScale(d), step = d.lineStep() + 2; + const int lx = x0 + PAGE_ICON_PX * s + 2 * s; // labels clear of the widest icon + int y = top + 1; + auto row = [&](const MiniIcon* ic, const char* label, const char* val) { + if (ic) miniIconDraw(d, x0, y + (lh - ic->h * s) / 2 - s, *ic); + d.setCursor(lx, y); d.print(label); + d.drawTextRightAlign(x1, y, val); + y += step; + }; + auto rule = [&]() { info::rule(d, x0, y + lh / 2 - 2, x1 - x0); y += lh / 2 + 2; }; + char buf[24]; +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + const bool on = _task->getGPSState(); + if (!loc) strcpy(buf, "none"); + else if (!on) strcpy(buf, "off"); + else if (!loc->isValid()) strcpy(buf, "no fix"); + else snprintf(buf, sizeof(buf), "%ld sats", loc->satellitesCount()); + row(&ICON_GPS, "GPS", buf); + int32_t mla, mlo; + if (_task->currentLocation(mla, mlo)) { + snprintf(buf, sizeof(buf), "%.5f", mla / 1e6); row(nullptr, "Lat", buf); + snprintf(buf, sizeof(buf), "%.5f", mlo / 1e6); row(nullptr, "Lon", buf); + } +#else + row(&ICON_GPS, "GPS", "none"); +#endif + rule(); + TrailStore& tr = _task->trail(); + if (tr.empty() && !tr.isActive()) strcpy(buf, "-"); + else geo::fmtDist(buf, sizeof(buf), tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true); + row(&ICON_TRAIL, "Trail", buf); + if (!tr.empty() || tr.isActive()) { + const uint32_t t = tr.elapsedSeconds(); + snprintf(buf, sizeof(buf), "%luh %02lum", (unsigned long)(t / 3600), (unsigned long)(t / 60 % 60)); + row(nullptr, tr.isActive() ? (tr.isPaused() ? "Paused" : "Rec") : "Time", buf); + snprintf(buf, sizeof(buf), "%d", tr.count()); + row(nullptr, "Points", buf); + } + rule(); + const float km = statusDistanceKm(); + int32_t tla, tlo; + const bool target = _task->activeTargetPos(tla, tlo); + const int live = _task->liveTrack().active(rtc_clock.getCurrentTime()); + snprintf(buf, sizeof(buf), "%d", live); + row(&ICON_MAP_CONTACT, "Live", buf); + if (live > 0 && km >= 0 && !target) { + geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); + row(nullptr, "Nearest", buf); + } + if (target) { + if (km >= 0) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); + else strcpy(buf, "-"); + row(&ICON_MAP_TARGET, "Target", buf); + } + snprintf(buf, sizeof(buf), "%d", _task->waypoints().count()); + row(&ICON_MAP_WAYPOINT, "Waypoints", buf); + } + + // The Favourites grid: landscape 3x2, portrait 2x3. + static int favCols(DisplayDriver& d) { return d.isLandscape() ? 3 : 2; } + + // The clock fields set in Settings (dashboard_fields), in order; their count. + int clockFields(uint8_t* out) { + int n = 0; + if (_node_prefs) + for (int k = 0; k < 3; k++) + if (_node_prefs->dashboard_fields[k] != telemetry::NONE && _node_prefs->dashboard_fields[k] < telemetry::COUNT) + out[n++] = _node_prefs->dashboard_fields[k]; + return n; + } + // One field's value without its noun (the label beside it names it); "-" when there is none. + void clockFieldValue(uint8_t f, char* val, int n) { + formatDashVal(f, val, n, _task->getBattMilliVolts(), _node_prefs->low_batt_mv, + _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(), + _task->getAnyUnreadOverflow(), _node_prefs->units_imperial, &sensors_lpp, false); + if (!val[0]) snprintf(val, n, "-"); + } + + // ── Clock page: the big clock, a seconds bar under it, the date, and the + // clock fields (Settings) as columns along the bottom, name over value. + // Without fields the clock and date sit in the middle. + // 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) 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) { + struct tm ti; + localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti); + const bool h12 = _node_prefs && _node_prefs->clock_12h; + const bool show_sec = !Features::IS_EINK && (!_node_prefs || !_node_prefs->clock_hide_seconds); + const int W = d.width(), H = d.height(); + d.setColor(DisplayDriver::LIGHT); + d.setTextSize(1); + const int lh = d.getLineHeight(); + + uint8_t fields[3]; + const int nf = clockFields(fields); + + // The month under the date, where it fits: a tall screen (portrait + // e-ink), or the 4.2" landscape one with the clock moved to the top. + const int fields_h = nf ? 2 * lh + 1 + lh / 2 : 0; + const int cal_h = calendar::height(d, ti); + bool cal = false; + int date_y; + if (d.height() > d.width()) { // portrait e-ink: stacked digits + date_y = drawClockTall(d, lh / 2, &ti, h12); + cal = true; + } else { + const BigClock bc = bigClockSize(d); + const int bar = 3 * bc.sc / 2; // the seconds bar and its gaps + const int block = bc.h + bar + 1 + lh; + cal = bc.sc + block + lh / 2 + 2 + cal_h <= H - fields_h; + const int y = nf || cal ? bc.sc : (H - block) / 2; + drawBigClock(d, W / 2, y, &ti, h12, bc.sc); + const int bar_y = y + bc.h + bar / 2 + 1; + if (show_sec) { // fills over the minute: dotted track, solid part + const int x0 = W / 8, bw = W - 2 * x0, fill = bw * ti.tm_sec / 60; + d.fillRect(x0, bar_y, fill, 1); + for (int x = x0 + fill + (fill & 1); x < x0 + bw; x += 2) d.fillRect(x, bar_y, 1, 1); + } + // Alarm armed: the bell at the bar's left end (the status bar, which + // shows it everywhere else, is hidden on this page). + if (_node_prefs && _node_prefs->alarm_on) miniIconDrawTop(d, W / 32, bar_y - 2, ICON_ALARM); + date_y = y + bc.h + bar + 1; + } + char buf[24]; + snprintf(buf, sizeof(buf), "%s %d %s %d", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], 1900 + ti.tm_year); + d.drawTextCentered(W / 2, date_y, buf); + + const int cal_top = date_y + lh + lh / 2 + 2; + if (cal && cal_h <= H - fields_h - cal_top) { // no wider than four characters a day + const int cw = W < 28 * d.getCharWidth() ? W : 28 * d.getCharWidth(); + calendar::draw(d, (W - cw) / 2, cal_top, cw, ti, alarmDays(ti)); + } + + if (nf == 0) return; + refresh_sensors(); + const int val_y = H - lh, lab_y = val_y - lh - 1, cw = W / nf; + for (int k = 0; k < nf; k++) { + char val[20]; + clockFieldValue(fields[k], val, sizeof(val)); + const int x0 = k * cw; + auto cell = [&](int yy, const char* t) { + if ((int)d.getTextWidth(t) <= cw - 2) d.drawTextCentered(x0 + cw / 2, yy, t); + else d.drawTextEllipsized(x0 + 1, yy, cw - 2, t); + }; + cell(lab_y, telemetry::LABEL[fields[k]]); + cell(val_y, val); + } + } + + // ── Lock screen: the status bar (drawn by render()), then one of two + // looks (Settings > Display > Lock clock): + // big -- the big clock, the date, unread messages in a pill; + // compact -- a small clock with the date beside it, the clock fields as + // rows below (plus unread messages when no field shows them). + // A key press on the lit screen (not the one that wakes it, so a glance + // shows the data) puts the unlock hint in a pill at the bottom for a few + // seconds (UITask::lockHintShown); e-ink keeps it up, it has no glance. + void drawLockPage(DisplayDriver& d) { + const int W = d.width(), H = d.height(); + d.setColor(DisplayDriver::LIGHT); + d.setTextSize(1); + const int lh = d.getLineHeight(), top = statusBarH(d) + 3; + const bool hint = Features::IS_EINK || _task->lockHintShown(); + const int pill_y = H - lh - 3; // the bottom pill, clear of what's above + const int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); + const uint32_t unix_ts = _rtc->getCurrentTime(); + const bool synced = unix_ts >= 1000000000UL; + struct tm ti; + if (synced) localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti); + const bool h12 = _node_prefs && _node_prefs->clock_12h; + const bool compact = _node_prefs && _node_prefs->lock_compact && W > H; + // The month and the rows under the big clock: on a tall screen (portrait + // e-ink), and on one 40 characters wide (the 4.2"). + const bool extras = H > W || W >= 40 * d.getCharWidth(); + + char pill[24] = ""; + if (compact) { + const int m = W / 16, x0 = W / 32; + int sc = bigClockSize(d).sc / 2; + if (sc < 1) sc = 1; + const int ch = lettering::LOGO_H * sc; + if (!synced) { + d.setCursor(x0, top + (ch - lh + 1) / 2); + d.print("No time sync"); + } else { // the date beside the clock, without the weekday if it won't fit + BigClockText bt(d, &ti, h12, sc); + bt.draw(d, x0, top); + char date[16]; + snprintf(date, sizeof(date), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]); + if (x0 + bt.w + lh / 2 + (int)d.getTextWidth(date) > W - x0) + snprintf(date, sizeof(date), "%d %s", ti.tm_mday, MON[ti.tm_mon]); + d.drawTextRightAlign(W - x0, top + (ch - lh + 1) / 2, date); + } + uint8_t rows[4]; + int nr = clockFields(rows); + bool has_msgs = false; + for (int k = 0; k < nr; k++) if (rows[k] == telemetry::MSGS) has_msgs = true; + if (!has_msgs && unread > 0) rows[nr++] = telemetry::MSGS; + if (nr) refresh_sensors(); + const int step = d.lineStep(); + for (int k = 0, y = top + ch + 6; k < nr && y + lh <= H; k++, y += step) { + if (hint && y + lh >= pill_y) break; // the hint pill takes the bottom + char val[20]; + clockFieldValue(rows[k], val, sizeof(val)); + info::valueRow(d, y, telemetry::LABEL[rows[k]], val, false, m - 2, m); + } + } else { + if (!synced) { + d.drawTextCentered(W / 2, H / 2 - lh, "No time sync"); + } else { + int date_y; + if (H > W) { + date_y = drawClockTall(d, top, &ti, h12); + } else { + const BigClock bc = bigClockSize(d); + drawBigClock(d, W / 2, top, &ti, h12, bc.sc); + date_y = top + bc.h + 3; + } + char buf[16]; + snprintf(buf, sizeof(buf), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]); + d.drawTextCentered(W / 2, date_y, buf); + if (extras) drawLockTallExtras(d, date_y + lh + lh / 2 + 2, hint ? pill_y - 3 : H, ti, unread); + } + if (!hint && unread > 0 && !(synced && extras)) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); + } + + if (hint) { + const int n = _task->lockSeqCount(); +#if defined(CARDKB_I2C) + const char* first = _task->hasCardKB() ? "Back+3xEnter/Fn+Esc" : "Hold Back + 3xEnter"; +#else + const char* first = "Hold Back + 3xEnter"; +#endif + snprintf(pill, sizeof(pill), "%s", n == 0 ? first : n == 1 ? "Enter x2 more..." : "Enter x1 more..."); + } + if (!pill[0]) return; + const int pw = d.getTextWidth(pill) + 6; + d.fillSoftRect((W - pw) / 2, pill_y, pw, lh + 3); + d.setColor(DisplayDriver::DARK); + d.drawTextCentered(W / 2, pill_y + 2, pill); + d.setColor(DisplayDriver::LIGHT); + } + + // Under the lock clock on a tall or a 4.2" screen, between y and bottom: the month, + // then a row each for unread messages, the armed alarm and the clock fields + // -- the rows first, the calendar only if there's room for it as well. + void drawLockTallExtras(DisplayDriver& d, int y, int bottom, const struct tm& ti, int unread) { + const int W = d.width(), m = W / 16, step = d.lineStep(), s = miniIconScale(d); + struct Row { const MiniIcon* ic; const char* label; char value[20]; } rows[6]; + int n = 0; + auto add = [&](const MiniIcon* ic, const char* label) -> char* { + rows[n].ic = ic; rows[n].label = label; rows[n].value[0] = 0; + return rows[n++].value; + }; + if (unread > 0) + snprintf(add(&ICON_PG_MSG, "Messages"), sizeof(rows[0].value), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); + if (_node_prefs && _node_prefs->alarm_on) + snprintf(add(&ICON_ALARM, "Alarm"), sizeof(rows[0].value), "%02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min); + uint8_t fields[3]; + const int nf = clockFields(fields); + if (nf) refresh_sensors(); + for (int k = 0; k < nf && n < 6; k++) { + if (fields[k] == telemetry::MSGS && unread > 0) continue; // already a row + clockFieldValue(fields[k], add(nullptr, telemetry::LABEL[fields[k]]), sizeof(rows[0].value)); + } + const int rows_h = n * step; + if (calendar::height(d, ti) + d.getLineHeight() / 2 + rows_h <= bottom - y) { + const int cw = W < 28 * d.getCharWidth() ? W : 28 * d.getCharWidth(); // as on the Clock page + calendar::draw(d, (W - cw) / 2, y, cw, ti, alarmDays(ti)); + y += calendar::height(d, ti) + d.getLineHeight() / 2; + } + for (int k = 0; k < n && y + d.getLineHeight() <= bottom; k++, y += step) { + int x = m; + if (rows[k].ic) { miniIconDraw(d, x, y, *rows[k].ic); x += rows[k].ic->w * s + 2 * s; } + info::valueRow(d, y, rows[k].label, rows[k].value, false, m - 2, x); + } + } + + // The Status page: four tiles split by dotted rules, each one subject's + // headline (icon + main value) over a detail line. A tile with the room + // (the 4.2" panel) adds a line and a chart of its history under it. Enter + // opens the Status screen with all of it. + void drawStatusTiles(DisplayDriver& d, int top) { + const int W = d.width(), H = d.height() - top; + const int lh = d.getLineHeight(), s = miniIconScale(d); + const int mid_x = W / 2, mid_y = top + H / 2; + const bool roomy = H / 2 >= 7 * lh; // three lines and a chart a tile + StatusScreen* st = (StatusScreen*)_task->statusScreen(); + d.setColor(DisplayDriver::LIGHT); + info::vrule(d, mid_x, top, H); + info::rule(d, 0, mid_y, W); + // The tile's box, inside the rules. + auto box = [&](int col, int row, int& x, int& y, int& w, int& h) { + x = col ? mid_x + 3 : 1; y = row ? mid_y + 2 : top + 1; + w = col ? W - x : mid_x - 2 - x; + h = (row ? top + H : mid_y - 1) - y; + }; + auto tile = [&](int col, int row, const MiniIcon* ic, float batt, const char* head, const char* detail, + const char* more = "") { + int x, y, w, h; + box(col, row, x, y, w, h); + const int iy = y + (lh - info::batteryH(d)) / 2 - s; + int tx = x; + if (ic) { miniIconDraw(d, x, iy, *ic); tx = x + ic->w * s + 2 * s; } + else if (batt >= 0) { info::battery(d, x, iy, batt); tx = x + info::batteryW(d) + 2 * s; } + d.drawTextEllipsized(tx, y, x + w - tx, head); + d.drawTextEllipsized(x, y + lh + 2, w, detail); + if (roomy && more[0]) d.drawTextEllipsized(x, y + 2 * (lh + 2), w, more); + }; + // Under a roomy tile's three lines: a history. + auto tileChart = [&](int col, int row, const info::History& hs, int min_span, bool bars) { + int x, y, w, h; + box(col, row, x, y, w, h); + const int cy = y + 3 * (lh + 2) + 2, ch = y + h - cy - 3; + if (ch >= 2 * lh) info::chart(d, x, cy, w - 4, ch, hs, min_span, bars, nullptr, nullptr); + }; + char h[20], t[20], m[28]; + + snprintf(h, sizeof(h), "%.3f", _node_prefs->freq); + snprintf(t, sizeof(t), "SF%u %gk", (unsigned)_node_prefs->sf, _node_prefs->bw); + const int nf = (int)radio_driver.getNoiseFloor(); + if (nf) snprintf(m, sizeof(m), "Noise %d dBm", nf); else m[0] = 0; + tile(0, 0, &ICON_PG_RADIO, -1, h, t, m); + if (roomy && st) tileChart(0, 0, st->noiseHistory(), 6, false); + +#if ENV_INCLUDE_GPS == 1 + { + LocationProvider* loc = sensors.getLocationProvider(); + const bool on = _task->getGPSState(); + t[0] = 0; + if (!loc) strcpy(h, "No GPS"); + else if (!on) strcpy(h, "GPS off"); + else if (!loc->isValid()) strcpy(h, "No fix"); + else { +#ifdef STATUS_HAS_SKY + GpsSky* g = skyview::sky(); + strcpy(h, g && g->fix_mode == 2 ? "2D fix" : "3D fix"); +#else + strcpy(h, "Fix"); +#endif + } + if (loc && on) snprintf(t, sizeof(t), "%ld sats", loc->satellitesCount()); + int32_t la, lo; + m[0] = 0; + if (loc && on && loc->isValid() && _task->currentLocation(la, lo)) snprintf(m, sizeof(m), "%.4f %.4f", la / 1e6, lo / 1e6); + tile(1, 0, &ICON_GPS, -1, h, t, m); + } +#else + tile(1, 0, &ICON_GPS, -1, "No GPS", ""); +#endif + + const int mv = _task->getBattMilliVolts(); + const int pct = battery::percent(mv, _node_prefs ? (int)_node_prefs->low_batt_mv : 0); + snprintf(h, sizeof(h), "%d%%", pct); + snprintf(t, sizeof(t), "%d.%02d V%s", mv / 1000, (mv % 1000) / 10, board.isExternalPowered() ? " USB" : ""); + tile(0, 1, nullptr, pct / 100.0f, h, t); + if (st && roomy) tileChart(0, 1, st->battHistory(), 20, false); + else if (st) { // the battery's trend beside its % + const int x0 = 1 + info::batteryW(d) + 2 * s + d.getTextWidth(h) + 3 * s, x1 = mid_x - 3; + if (x1 - x0 >= 12) info::spark(d, x0, mid_y + 2, x1 - x0, lh - 1, st->battHistory(), 20); + } + + const int nc = the_mesh.getNumContacts(); + snprintf(h, sizeof(h), "%d node%s", nc, nc == 1 ? "" : "s"); + snprintf(t, sizeof(t), "%d in 1 h", StatusScreen::heardLastHour()); + snprintf(m, sizeof(m), "RX %lu TX %lu", (unsigned long)StatusScreen::totalRx(), (unsigned long)StatusScreen::totalTx()); + tile(1, 1, &ICON_MAP_CONTACT, -1, h, t, m); + if (roomy && st) tileChart(1, 1, st->trafficHistory(), 4, true); + } + + // Small 5x5 glyph shown in the page-indicator row for each HomePage. + static const MiniIcon* pageIcon(int page) { + switch (page) { + case CLOCK: return &ICON_PG_CLOCK; + case FAVOURITES: return &ICON_PG_STAR; + case STATUS: return &ICON_CHART; + case BLUETOOTH: return &ICON_PG_BT; + case ADVERT: return &ICON_PG_ADVERT; + case SETTINGS: return &ICON_PG_SETTINGS; + case MAP: return &ICON_PG_MAP; + case TOOLS: return &ICON_PG_TOOLS; + case QUICK_MSG: return &ICON_PG_MSG; + case SHUTDOWN: return &ICON_PG_POWER; + } + return nullptr; + } + + int render(DisplayDriver& display) override { + char tmp[80]; + int mq_delay = 0; // >0 while a selected row's name is marquee-scrolling + int anim_ms = 0; // >0 while something animates: a page turn + display.setTextSize(1); + const int lh = display.getLineHeight(); // line height at sz1 + const int step = display.lineStep(); // lh + 2 + // Page-indicator row: small page icons (PAGE_ICON_PX) replace the old dots. Centre and + // gap scale with the font so the band clears the header above and content + // below (identical to the old lh+4 / +6 dots layout at 1x). + const int pg_half = (PAGE_ICON_PX * statusIconScale(display) + 1) / 2; // page icons follow STATUS_ICON_SCALE + const int dots_y = statusBarH(display) + pg_half + 1; // icon-row centre, below the header + const int content_y = contentTop(display); // first content row, below the icons + const float slide = slideProgress(); // -1, or how far a page turn has got + + // Title bar displaying node name (except on lock screen), status icons and battery. + // Hidden on fullscreen pages (CLOCK). + if (_page != CLOCK) { + display.setColor(DisplayDriver::LIGHT); + // The lock page has nothing on the left of the bar (its clock sits + // below it), so the status icons may use the whole row. + const int lock_reserve = _page == LOCK ? 0 : -1; + int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts(), lock_reserve); + display.setColor(DisplayDriver::LIGHT); + + if (_page != LOCK) { + const int bar_ty = (statusBarH(display) - lh) / 2; // text centred in a tall bar (STATUS_ICON_SCALE) + // The time, once the clock is set (the node name before that): the + // name is what others see, the time is what you glance at here. + char filtered_name[sizeof(_node_prefs->node_name)]; + uint32_t now_ts = _rtc->getCurrentTime(); + if (now_ts >= 1000000000UL) { + struct tm lt; + localTm(now_ts, _node_prefs->tz_offset_hours, lt); + int hh = lt.tm_hour; + if (_node_prefs->clock_12h) { hh %= 12; if (hh == 0) hh = 12; } + snprintf(filtered_name, sizeof(filtered_name), "%d:%02d", hh, lt.tm_min); + if (display.width() >= 40 * display.getCharWidth()) // the 4.2": the date beside it + snprintf(filtered_name + strlen(filtered_name), sizeof(filtered_name) - strlen(filtered_name), + " %s %d %s", WDAY[lt.tm_wday], lt.tm_mday, MON[lt.tm_mon]); + } else { + display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); + } + + // Only show the live-power readout when APC is actually controlling power — + // not merely when the pref is set. While repeating APC is suppressed and + // power is pinned to the ceiling, so apcActive() is false and the name bar + // drops the readout (matching the "--" lock in Settings). + if (the_mesh.apcActive()) { + char pwr_buf[8]; + snprintf(pwr_buf, sizeof(pwr_buf), "%ddB", (int)radio_driver.getTxPower()); + int pwr_w = display.getTextWidth(pwr_buf); + display.drawTextEllipsized(0, bar_ty, rightEdge - 2 - pwr_w - 2, filtered_name); + display.drawTextRightAlign(rightEdge - 2, bar_ty, pwr_buf); + } else { + display.drawTextEllipsized(0, bar_ty, rightEdge - 2, filtered_name); + } + } + } + + // ensure current page is visible (e.g. after settings change) + if (!isPageVisible(_page)) _page = navPage(_page, +1); + + // curr page indicator — a row of small page icons, one per visible page, with + // the current page underlined. Hidden on CLOCK and LOCK (full screen used for + // the clock/dashboard). + if (_page != CLOCK && _page != LOCK) { + int order[(int)Count]; int n = buildVisibleOrder(order); + int curr_vis = 0; + for (int i = 0; i < n; i++) if (order[i] == _page) { curr_vis = i; break; } + const int s = statusIconScale(display); // STATUS_ICON_SCALE, or the font's + const int icon_w = PAGE_ICON_PX * s; + int pitch = icon_w + 5 * s; // comfortable spacing + if (n > 1) { // shrink to fit if many pages + int fit = (display.width() - icon_w) / (n - 1); + if (fit < pitch) pitch = fit; + } + int x = display.width() / 2 - pitch * (n - 1) / 2; + // The current page: its icon knocked out of a soft pill. During a page + // turn the pill glides over from the page being left. + int pw = icon_w + 4; + if (pw > pitch - 1) pw = pitch - 1; + int pill_x = x + pitch * curr_vis; + if (slide >= 0) { + for (int i = 0; i < n; i++) + if (order[i] == _slide_from) { pill_x = x + pitch * i + (int)((pill_x - x - pitch * i) * slide + 0.5f); break; } + } + display.setColor(DisplayDriver::LIGHT); + display.fillSoftRect(pill_x - pw / 2, dots_y - pg_half - 1, pw, pg_half * 2 + 3); + for (int i = 0; i < n; i++) { + const MiniIcon* ic = pageIcon(order[i]); + const int d2 = 2 * (x - pill_x < 0 ? pill_x - x : x - pill_x); + if (d2 <= pw - icon_w) display.setColor(DisplayDriver::DARK); // inside the pill + else if (d2 < pw + icon_w) { x += pitch; continue; } // half under the gliding pill + else display.setColor(DisplayDriver::LIGHT); + if (ic) miniIconDrawCenteredScaled(display, x, dots_y, *ic, s); + x += pitch; + } + display.setColor(DisplayDriver::LIGHT); + } + + if (_page == HomePage::CLOCK) { + uint32_t unix_ts = _rtc->getCurrentTime(); + if (unix_ts < 1000000000UL) { + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + int mid_y = display.height() / 2 - step; + display.drawTextCentered(display.width() / 2, mid_y, "! No time sync"); + display.drawTextCentered(display.width() / 2, mid_y + step, "Enable GPS or"); + display.drawTextCentered(display.width() / 2, mid_y + step * 2, "connect app"); + } else { + drawClockPage(display, unix_ts); + } + } else if (_page == HomePage::LOCK) { + drawLockPage(display); + } else if (_page == HomePage::STATUS) { + drawStatusTiles(display, content_y); + } else if (isQuickPage(_page) && !(_page == HomePage::SHUTDOWN && _shutdown_init)) { + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + QRow rows[QUICK_MAX]; + const int n = buildQuick(_page, rows); + int q = drawQuickPanel(display, content_y, rows, n); + if (_page == HomePage::TOOLS && _task->isTimerRunning()) q = 1000; // the timer's countdown + if (q > 0 && (mq_delay <= 0 || q < mq_delay)) mq_delay = q; + } else if (_page == HomePage::MAP) { + drawMapPage(display, content_y + 4); // the gap under the page icons + } else if (_page == HomePage::FAVOURITES) { + // Grid of pinned contacts. Layout transposes to current orientation: + // landscape → 3×2, portrait → 2×3. The selected card is filled. + // No title — node name + battery (top bar) and the page-dots indicator above + // serve as the page identity. + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + + const int cols = favCols(display); + const int rows = NodePrefs::FAVOURITES_COUNT / cols; + const int grid_y = content_y + 4; // the gap under the page icons, as on every page + const int grid_h = display.height() - grid_y; + const int cell_w = display.width() / cols; + const int cell_h = grid_h / rows; + const int line_h = display.getLineHeight(); + + if (_fav_sel >= NodePrefs::FAVOURITES_COUNT) _fav_sel = 0; + + bool fav_changed = false; // a stale (gone) slot was pruned this pass → persist once after the loop + for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) { + int row = i / cols; + int col = i % cols; + int cx = col * cell_w; + int cy = grid_y + row * cell_h; + bool sel = (i == _fav_sel); + const uint8_t* prefix = favSlotPrefix(i); + // Each slot a card: a soft frame round a pinned one, a dotted outline + // round a free one, filled while selected. + const int tx = cx + 1, ty = cy, tw = cell_w - 2, th = cell_h - 2; + display.setColor(DisplayDriver::LIGHT); + if (sel) { display.fillSoftRect(tx, ty, tw, th); display.setColor(DisplayDriver::DARK); } + else if (prefix) display.drawSoftRect(tx, ty, tw, th); + else { + for (int x = tx + 2; x < tx + tw - 1; x += 2) { display.fillRect(x, ty, 1, 1); display.fillRect(x, ty + th - 1, 1, 1); } + for (int y = ty + 2; y < ty + th - 1; y += 2) { display.fillRect(tx, y, 1, 1); display.fillRect(tx + tw - 1, y, 1, 1); } + } + + char name[26]; + uint8_t unread = 0; + bool overflow = false; + bool resolved = false; + uint32_t last_ts = 0; // heard from / last post, for a tall card + int32_t plat = 0, plon = 0; // a contact's advertised position + const char* last_text = nullptr; // the newest message, for a big card + bool last_out = false; + + if (prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL) { + uint8_t ch_idx = prefix[0]; + ChannelDetails ch; + if (the_mesh.getChannel(ch_idx, ch) && ch.name[0]) { + // '#' marks a channel apart from a contact tile — the two share the + // grid and Enter does something different on each. + name[0] = '#'; + display.translateUTF8ToBlocks(name + 1, chanctl::bareName(ch.name), sizeof(name) - 1); + unread = _task->getChannelUnread(ch_idx); + overflow = unread > 0 && _task->getChannelUnreadOverflow(ch_idx); + resolved = true; + MessageHistory& h = _task->core().history; + for (int j = 0; j < h.chHistCount(); j++) { + const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j)); + if (e.ch_idx == ch_idx) { last_ts = e.timestamp; last_text = e.text; break; } + } + } + } else if (prefix) { + for (int idx = 0; ; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (memcmp(c.id.pub_key, prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + display.translateUTF8ToBlocks(name, c.name, sizeof(name)); + unread = _task->getDMUnread(c.id.pub_key); + overflow = unread > 0 && _task->getDMUnreadOverflow(c.id.pub_key); + resolved = true; + last_ts = c.lastmod; + plat = c.gps_lat; plon = c.gps_lon; + MessageHistory& h = _task->core().history; + const int rp = h.dmHistEntryForContact(c.id.pub_key, 0); + if (rp >= 0) { last_text = h.dmAtPos(rp).text; last_out = h.dmAtPos(rp).outgoing; } + break; + } + } + } + + if (prefix && !resolved) { + // The pinned target is gone — prefs outlived the contact/channel list + // (e.g. a wiped /contacts3; onContactRemoved/onChannelRemoved only + // catch a live delete). Clear the stale slot so it renders as an empty + // "+" tile instead of a blank one. Persisted once after the loop. + _task->clearFavouriteSlot(i); + fav_changed = true; + } + + if (resolved) { + // Reserve space for the unread badge so the name's ellipsis lands + // before it instead of underneath. Badge and name share one baseline. + int bw = unread > 0 ? display.unreadBadgeWidth(unread, overflow) + 3 : 0; // badge + 3 px gap + // A tall card (e-ink) has the name at the top and lines of detail + // under it: when it was last heard (a channel: its last post) and, + // for a contact with a position, how far away it is. + const bool tall = th >= 3 * line_h + 6; + int name_y = tall ? ty + 3 : ty + (th - line_h) / 2; + int name_max_w = tw - 6 - bw; + if (name_max_w < 6) name_max_w = 6; + int r = display.drawTextEllipsized(tx + 3, name_y, name_max_w, name, sel); + if (sel && r > 0) mq_delay = r; + if (unread > 0) + display.drawUnreadBadge(tx + tw - 3, name_y, unread, sel, overflow); + // A card six lines tall on a screen 40 characters wide (the 4.2"): + // the newest message under the name, how long ago and how far along + // the bottom. + if (th >= 6 * line_h && display.width() >= 40 * display.getCharWidth()) { + display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); + const int by = ty + th - line_h - 3; // the bottom line + const uint32_t now = rtc_clock.getCurrentTime(); + char buf[16]; + if (last_ts) { + geo::fmtAgeShort(buf, sizeof(buf), now, last_ts); + display.setCursor(tx + 3, by); display.print(buf); + } + int32_t mla, mlo; + if ((plat || plon) && _task->currentLocation(mla, mlo)) { + geo::fmtDist(buf, sizeof(buf), geo::haversineKm(mla, mlo, plat, plon), _task->useImperial(), true); + display.drawTextRightAlign(tx + tw - 3, by, buf); + } + const int my = name_y + line_h + line_h / 3; // the message, wrapped + const int nl_max = (by - 2 - my) / line_h; + if (nl_max > 0) { + char pv[MSG_TEXT_BUF + 4], tr_pv[MSG_TEXT_BUF + 4]; + if (last_text) snprintf(pv, sizeof(pv), "%s%s", last_out ? "Me: " : "", last_text); + else snprintf(pv, sizeof(pv), "No messages"); + display.translateUTF8ToBlocks(tr_pv, pv, sizeof(tr_pv)); + char lines[5][FS_CHARS_MAX]; + const int cap = nl_max < 4 ? nl_max : 4; + int n = FullscreenMsgView::wrapLines(display, tr_pv, tw - 6, lines, cap + 1); + if (n > cap) { // more than fits: the last line shown ends in an ellipsis + n = cap; + const size_t l = strlen(lines[n - 1]); + if (l + 4 < sizeof(lines[0])) strcat(lines[n - 1], " ..."); + } + for (int k = 0; k < n; k++) display.drawTextEllipsized(tx + 3, my + k * line_h, tw - 6, lines[k]); + } + } else if (tall) { + display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); // the badge leaves its own + const int s = miniIconScale(display), gap = line_h / 3; + int y = name_y + line_h + gap; + auto detail = [&](const MiniIcon& ic, const char* text) { + if (y + line_h > ty + th - 2) return; + miniIconDraw(display, tx + 3, y, ic); + const int x = tx + 3 + ic.w * s + 2 * s; + display.drawTextEllipsized(x, y, tx + tw - 3 - x, text); + y += line_h + gap; + }; + char buf[16]; + const uint32_t now = rtc_clock.getCurrentTime(); + if (last_ts) { + geo::fmtAgeShort(buf, sizeof(buf), now, last_ts); + detail(prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL ? ICON_PG_MSG : ICON_PG_CLOCK, buf); + } + int32_t mla, mlo; + if ((plat || plon) && _task->currentLocation(mla, mlo)) { + // The distance after an arrow towards it (north up). + geo::fmtDist(buf, sizeof(buf), geo::haversineKm(mla, mlo, plat, plon), _task->useImperial(), true); + detail(*ICON_ARROWS[((geo::bearingDeg(mla, mlo, plat, plon) + 22) % 360) / 45], buf); + } + } + } else { + display.drawTextCentered(tx + tw / 2, ty + (th - line_h) / 2, "+"); + } + if (sel) display.setColor(DisplayDriver::LIGHT); + } + // Persist any pruned slots once, outside the loop — a render pass can clear + // several gone tiles but only one flash write is needed. Self-healing: once + // cleared, the slot is empty next frame so this can't re-fire per frame. + if (fav_changed) the_mesh.savePrefs(); + if (_tile_menu.active) _tile_menu.render(display); + } else if (_page == HomePage::SHUTDOWN) { + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, content_y + step, "hibernating..."); // the panel's Hibernate row + } + if (slide >= 0) { // page turn: the page left behind slides out, this one in + display.slideCompose(_slide_dir * (int)(display.width() * slide + 0.5f)); + anim_ms = 15; + } + if (Features::IS_EINK) { + // slow display: poll every 30 s; inbound msgs force immediate refresh via notify() + return (mq_delay > 0 && mq_delay < Features::HOME_REFRESH_MS) ? mq_delay : Features::HOME_REFRESH_MS; + } + if (_page == HomePage::CLOCK) { + bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; + int ret = show_sec ? 1000 : 60000; + if (anim_ms > 0 && anim_ms < ret) ret = anim_ms; + return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; + } + int ret = 5000; + if (anim_ms > 0 && anim_ms < ret) ret = anim_ms; + return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; + } + + bool handleInput(char c) override { + // Favourites grid claims joystick UP/DOWN and inner LEFT/RIGHT; LEFT at the + // left column and RIGHT at the right column fall through to page nav so the + // user can still leave the page sideways. + if (_page == HomePage::FAVOURITES) { + // The tile menu consumes all input while open. + if (_tile_menu.active) { + auto res = _tile_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _pin_target_slot >= 0) { + if (_tile_menu.selectedIndex() == 1) { // Replace + _task->pickFavouriteTarget(_pin_target_slot); + _pin_target_slot = -1; + return true; + } + _task->clearFavouriteSlot(_pin_target_slot); + the_mesh.savePrefs(); + } + if (res != PopupMenu::NONE) _pin_target_slot = -1; + return true; + } + DisplayDriver* d = _task->getDisplay(); + const int cols = d ? favCols(*d) : 2; + const int rows = NodePrefs::FAVOURITES_COUNT / cols; + int col = _fav_sel % cols; + int row = _fav_sel / cols; + if ((c == KEY_LEFT || c == KEY_PREV) && col > 0) { _fav_sel--; return true; } + if ((c == KEY_RIGHT || c == KEY_NEXT) && col < cols - 1) { _fav_sel++; return true; } + if (c == KEY_UP && row > 0) { _fav_sel -= cols; return true; } + if (c == KEY_DOWN && row < rows - 1) { _fav_sel += cols; return true; } + if (c == KEY_CONTEXT_MENU) { + // Filled tile → Unpin / Replace. An empty tile has nothing to offer: + // its Enter already opens the picker. + if (favSlotPrefix(_fav_sel)) { + _pin_target_slot = _fav_sel; + _tile_menu.begin("Slot options", 2); + _tile_menu.addItem("Unpin"); + _tile_menu.addItem("Replace"); + } + return true; + } + if (c == KEY_ENTER) { + // Filled slot → open its conversation, empty slot → in-place pin picker. + const uint8_t* pfx = favSlotPrefix(_fav_sel); + if (!pfx) { _task->pickFavouriteTarget(_fav_sel); return true; } + if (_task->favouriteSlotKind(_fav_sel) == NodePrefs::FAV_KIND_CHANNEL) { + _task->openChannelHistory(pfx[0]); + return true; + } + for (int idx = 0; ; idx++) { + ContactInfo c2; + if (!the_mesh.getContactByIdx(idx, c2)) break; + if (memcmp(c2.id.pub_key, pfx, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + // A room opens through its own entry point: posting to one needs a + // login handshake that a plain DM view would skip. + if (c2.type == ADV_TYPE_ROOM) _task->openRoomServer(c2); + else _task->openContactDM(c2); + return true; + } + } + _task->showAlert("Contact not found", 800); + return true; + } + // Edge LEFT/RIGHT and unhandled keys fall through to page nav below. + } + + if (isQuickPage(_page) && _quick_edit) { + // Changing a bar: shown live, saved once on the way out. + if ((c == KEY_LEFT || c == KEY_RIGHT) && _node_prefs) { + QRow rows[QUICK_MAX]; + const int n = buildQuick(_page, rows); + const QAct act = n > 0 ? rows[_quick_sel < n ? _quick_sel : n - 1].act : QA_NONE; + const int d = c == KEY_RIGHT ? 1 : -1; + if (act == QA_BRIGHTNESS) { + uint8_t& b = _node_prefs->display_brightness; + const uint8_t nb = (uint8_t)constrain((int)b + d, 0, 4); + if (nb != b) { b = nb; _quick_dirty = true; _task->applyDisplayPrefs(); } + } else if (act == QA_AUTO_ADVERT) { + const int i = constrain(AutoAdvertScreen::indexOf(_node_prefs->advert_auto_interval_sec) + d, + 0, AutoAdvertScreen::OPT_COUNT - 1); + if (_node_prefs->advert_auto_interval_sec != AutoAdvertScreen::OPTS[i]) { + _node_prefs->advert_auto_interval_sec = AutoAdvertScreen::OPTS[i]; + _quick_dirty = true; + } + } + return true; + } + if (c == KEY_ENTER || c == KEY_CANCEL) { + _quick_edit = false; + _task->savePrefsIfDirty(_quick_dirty); + _quick_dirty = false; + return true; + } + return true; // nothing else while editing + } + if (isQuickPage(_page)) { + QRow rows[QUICK_MAX]; + const int n = buildQuick(_page, rows); + if (c == KEY_UP) { if (_quick_sel > 0) _quick_sel--; return true; } + if (c == KEY_DOWN) { if (_quick_sel + 1 < n) _quick_sel++; return true; } + if (c == KEY_ENTER && n > 0) { quickAct(_page, rows[_quick_sel < n ? _quick_sel : n - 1]); return true; } + } + if (c == KEY_LEFT || c == KEY_PREV) { + turnPage(-1); + return true; + } + if (c == KEY_NEXT || c == KEY_RIGHT) { + turnPage(+1); + return true; + } + if (c == KEY_ENTER && _page == HomePage::STATUS) { + _task->gotoStatusScreen(StatusScreen::TAB_RADIO); + return true; + } + if (c == KEY_ENTER && _page == HomePage::MAP) { + _task->gotoMapScreen(); + 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; + } + if (c == KEY_CONTEXT_MENU && _page == HomePage::MAP) { + _task->quickShareMyLocation(); + return true; + } + return false; + } +}; diff --git a/examples/companion_radio/ui-new/PerfL1.h b/examples/companion_radio/ui-new/PerfL1.h new file mode 100644 index 00000000..d718f287 --- /dev/null +++ b/examples/companion_radio/ui-new/PerfL1.h @@ -0,0 +1,138 @@ +#pragma once +// Included into UITask.cpp only. + +#if defined(UI_PERF_L1) && defined(NRF52_PLATFORM) +// -D UI_PERF_L1: walks the main screens by itself (keys fed into the queue) and +// prints, per step over USB serial: frames drawn, frames the display skipped +// as unchanged, render/flush time (avg/max us), heap (used/free/largest +// block) and every FreeRTOS task's stack headroom. Measurement only. +#include "FreeRTOS.h" +#include "task.h" +#include +extern unsigned char __HeapBase[]; +extern unsigned char __HeapLimit[]; +extern uint32_t g_disp_flushes; +namespace perfl1 { + // Each step opens a screen directly (so the walk doesn't depend on which + // Home pages are enabled), then types keys: R/L/U/D/E/X, H = hold Enter. + enum Open : uint8_t { NONE, HOME, SETTINGS, MESSAGES, TOOLS, NEARBY, TRAIL, MAP, COMPASS, STATUS, + LIVE, LOCATOR, ADVERT, REPEATER, BOT, CLOCKTOOLS, CHANNEL0 }; + struct Step { const char* name; Open open; const char* keys; uint16_t dwell_ms; }; + static const Step STEPS[] = { + {"boot-idle", NONE, "", 3000}, + {"home", HOME, "", 2000}, + {"home-R1", NONE, "R", 1500}, {"home-R2", NONE, "R", 1500}, {"home-R3", NONE, "R", 1500}, + {"home-R4", NONE, "R", 1500}, {"home-R5", NONE, "R", 1500}, {"home-R6", NONE, "R", 1500}, + {"home-R7", NONE, "R", 1500}, {"home-R8", NONE, "R", 1500}, + {"home-R9", NONE, "R", 1500}, {"home-R10", NONE, "R", 1500}, {"home-R11", NONE, "R", 1500}, + {"home-R12", NONE, "R", 1500}, {"home-R13", NONE, "R", 1500}, + {"settings", SETTINGS, "", 2000}, {"set-display", NONE, "E", 2000}, + {"set-scroll", NONE, "DDDDDDDDDDDD", 2000}, + {"messages", MESSAGES, "", 2000}, {"dm-list", NONE, "E", 2000}, + {"dm-scroll", NONE, "DDDDDDDDDDDDDDDDDDDD", 2000}, + {"dm-thread", NONE, "E", 3000}, {"dm-thread-up", NONE, "UUUU", 2000}, + {"channel", CHANNEL0, "", 3000}, {"channel-up", NONE, "UUUUUU", 2000}, + {"compose", NONE, "EE", 2000}, {"kbd-type", NONE, "RRDRRDRRE", 2000}, + {"kbd-close", NONE, "XXX", 1500}, + {"nearby", NEARBY, "", 3000}, {"nearby-scroll", NONE, "DDDDDDDDDDDDDDDDDDDD", 2000}, + {"tools", TOOLS, "", 1500}, {"trail", TRAIL, "", 2000}, {"map", MAP, "", 3000}, + {"compass", COMPASS, "", 3000}, + {"st-radio", STATUS, "", 3000}, {"st-radio-dn", NONE, "DD", 2000}, {"st-gps", NONE, "UUR", 3000}, + {"st-gps-dn", NONE, "DDD", 2000}, {"st-sky", NONE, "UUUR", 4000}, {"st-signal", NONE, "E", 3000}, + {"st-power", NONE, "R", 2000}, {"st-mesh", NONE, "R", 2000}, {"st-mesh-dn", NONE, "DDD", 2000}, + {"st-system", NONE, "UUUR", 2000}, {"st-system-dn", NONE, "DDDDDD", 2000}, + {"liveshare", LIVE, "", 1500}, {"locator", LOCATOR, "", 1500}, {"autoadvert", ADVERT, "", 1500}, + {"repeater", REPEATER, "", 1500}, {"bot", BOT, "", 1500}, {"clocktools", CLOCKTOOLS, "", 1500}, + {"home-end", HOME, "", 3000}, + {"done", NONE, "", 500}, + }; + static int step = -1; + static uint32_t step_at = 0, frames = 0, r_sum = 0, r_max = 0, f_sum = 0, f_max = 0, fl0 = 0; + static uint32_t heap_min_free = 0xFFFFFFFF; + + static uint32_t largestBlock() { // bisect the biggest malloc that succeeds + uint32_t lo = 0, hi = (uint32_t)(__HeapLimit - __HeapBase); + while (hi - lo > 64) { + uint32_t mid = (lo + hi) / 2; + void* q = malloc(mid); + if (q) { free(q); lo = mid; } else hi = mid; + } + return lo; + } + static void heap(uint32_t& used, uint32_t& freeb) { + uint32_t total = (uint32_t)(__HeapLimit - __HeapBase); + used = (uint32_t)mallinfo().uordblks; + freeb = used < total ? total - used : 0; + } + void frame(uint32_t render_us, uint32_t flush_us) { + frames++; r_sum += render_us; f_sum += flush_us; + if (render_us > r_max) r_max = render_us; + if (flush_us > f_max) f_max = flush_us; + } + static void report(const char* name) { + uint32_t used, freeb; heap(used, freeb); + if (freeb < heap_min_free) heap_min_free = freeb; + uint32_t fl = g_disp_flushes - fl0; + Serial.printf("PERF %-12s frames %3lu sent %3lu render avg %5lu max %6lu flush avg %5lu max %6lu us | heap used %lu free %lu largest %lu min %lu\n", + name, frames, fl, frames ? r_sum / frames : 0, r_max, frames ? f_sum / frames : 0, f_max, + used, freeb, largestBlock(), heap_min_free); + } + static void tasks() { + TaskStatus_t st[16]; + UBaseType_t n = uxTaskGetSystemState(st, 16, NULL); + for (UBaseType_t i = 0; i < n; i++) + Serial.printf("TASK %-10s stack free %5lu B\n", st[i].pcTaskName, (unsigned long)st[i].usStackHighWaterMark * sizeof(StackType_t)); + } + static DisplayDriver* disp = nullptr; + static void shot(const char* name) { // the 1 KB page buffer as hex, for a PNG on the host + if (!disp || !disp->getBuffer()) return; + const uint8_t* b = disp->getBuffer(); + Serial.printf("SHOT %s ", name); + for (uint16_t i = 0; i < disp->getBufferSize(); i++) Serial.printf("%02x", b[i]); + Serial.println(); + } + static char keyOf(char c) { + switch (c) { case 'R': return KEY_RIGHT; case 'L': return KEY_LEFT; case 'U': return KEY_UP; + case 'D': return KEY_DOWN; case 'E': return KEY_ENTER; case 'X': return KEY_CANCEL; case 'H': return KEY_CONTEXT_MENU; } + return 0; + } + static const char* pending = nullptr; + static uint32_t next_key_at = 0; + // Returns a key to enqueue (0 = none). + Open open_now = NONE; + char tick() { + uint32_t now = millis(); + if (step >= (int)(sizeof(STEPS) / sizeof(STEPS[0]))) return 0; + if (pending && *pending) { + if ((int32_t)(now - next_key_at) < 0) return 0; + next_key_at = now + 300; + char k = keyOf(*pending++); + if (!*pending) { step_at = now; frames = r_sum = r_max = f_sum = f_max = 0; fl0 = g_disp_flushes; } + return k; + } + if (step >= 0 && (int32_t)(now - step_at) < (int32_t)STEPS[step].dwell_ms) return 0; + if (step >= 0) { report(STEPS[step].name); shot(STEPS[step].name); } + step++; + if (step >= (int)(sizeof(STEPS) / sizeof(STEPS[0]))) { tasks(); Serial.println("PERF end"); return 0; } + if (step == 0) tasks(); + pending = STEPS[step].keys; + open_now = STEPS[step].open; + step_at = now; frames = r_sum = r_max = f_sum = f_max = 0; fl0 = g_disp_flushes; + next_key_at = now; + return 0; + } +} +// micros() ticks in ~1 ms steps here (RTC-based), so time with the CPU cycle counter. +static inline uint32_t perfUs() { + static bool on = false; + if (!on) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; on = true; } + return DWT->CYCCNT; // cycles; differences stay right across the 67 s wrap +} +#define PERF_T0() uint32_t _pf0 = perfUs() +#define PERF_T1() uint32_t _pf1 = perfUs() +#define PERF_T2() perfl1::frame((_pf1 - _pf0) / (SystemCoreClock / 1000000), (perfUs() - _pf1) / (SystemCoreClock / 1000000)) +#else +#define PERF_T0() +#define PERF_T1() +#define PERF_T2() +#endif diff --git a/examples/companion_radio/ui-new/SplashScreen.h b/examples/companion_radio/ui-new/SplashScreen.h new file mode 100644 index 00000000..7806e134 --- /dev/null +++ b/examples/companion_radio/ui-new/SplashScreen.h @@ -0,0 +1,98 @@ +#pragma once +// Included into UITask.cpp only (see the note on screen fragments there). + + +// Boot splash, as on the L2 (ui-lvgl/Splash.h, same lettering from +// ui-core/Lettering.h): the MeshCore wordmark rising into place, "solo" in its +// lettering once it has landed, the Solo version, upstream version + build date, and three dots +// rising in turn while the device starts. E-ink gets the still final frame. +class SplashScreen : public UIScreen { + UITask* _task; + unsigned long _start, dismiss_after; + char _solo_ver[24]; + char _line2[32]; + + // A word in the wordmark's lettering at `sc` px a pixel, top-left at (x, y). + static void drawLettering(DisplayDriver& d, const char* text, int x, int y, int gap, int sc) { + for (const char* p = text; *p; p++) { + char c = (char)tolower((unsigned char)*p); + int cw = lettering::charW(c); + if (cw < 0) continue; + for (int r = 0; r < lettering::LOGO_H; r++) + for (int col = 0; col < cw; ) { // runs of inked pixels as one rect + if (!lettering::inked(c, col, r)) { col++; continue; } + int e = col; + while (e + 1 < cw && lettering::inked(c, e + 1, r)) e++; + d.fillRect(x + col * sc, y + r * sc, (e - col + 1) * sc, sc); + col = e + 1; + } + x += cw * sc + gap; + } + } + +public: + SplashScreen(UITask* task) : _task(task) { + lettering::soloVersion(_solo_ver, sizeof(_solo_ver)); + char date[16]; + lettering::buildDate(date, sizeof(date)); +#ifdef MESHCORE_VERSION + snprintf(_line2, sizeof(_line2), "%s %s", MESHCORE_VERSION, date); +#else + snprintf(_line2, sizeof(_line2), "%s", date); +#endif + _start = millis(); + dismiss_after = _start + BOOT_SCREEN_MILLIS; + } + + int render(DisplayDriver& display) override { + const bool anim = !display.isEink(); + const unsigned long t = anim ? millis() - _start : 100000UL; // e-ink: the settled frame + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + const int lh = display.getLineHeight(); + const int cx = display.width() / 2; + + // The wordmark and "solo" grow in whole pixels with the panel (1x on 128 px, + // 3x on the 4.2"), as far as the block still fits over the dots. + const int dots_h = 8; + int sc = display.width() / lettering::LOGO_W; + while (sc > 1 && (lettering::LOGO_H * 2 + 5) * sc + lh * 2 + 2 > display.height() - dots_h) sc--; + if (sc < 1) sc = 1; + + // Block from the wordmark down to the second text line, centred above the dots. + const int logo_h = lettering::LOGO_H * sc; + const int block_h = logo_h * 2 + 5 * sc + lh * 2 + 2; + int top = (display.height() - dots_h - block_h) / 2; + if (top < 0) top = 0; + + // Wordmark: rises 6 px into place over the first 400 ms (ease-out). + int rise = 0; + if (t < 400) { int k = 400 - (int)t; rise = (6 * sc * k * k) / (400 * 400); } + const int lx = (display.width() - lettering::LOGO_W * sc) / 2; + for (int r = 0; r < lettering::LOGO_H; r++) + for (int col = 0; col < lettering::LOGO_W; ) { // runs of inked pixels as one rect + if (!lettering::logoBit(col, r)) { col++; continue; } + int e = col; + while (e + 1 < lettering::LOGO_W && lettering::logoBit(e + 1, r)) e++; + display.fillRect(lx + col * sc, top + rise + r * sc, (e - col + 1) * sc, sc); + col = e + 1; + } + + int y = top + logo_h + 3 * sc; + const int solo_w = lettering::textW("solo", 0) * sc + 2 * sc * 3; + if (t >= 400) drawLettering(display, "solo", cx - solo_w / 2, y, 2 * sc, sc); // once the wordmark has landed + y += logo_h + 2 * sc; + display.drawTextCentered(cx, y, _solo_ver[0] ? _solo_ver : "dev"); + y += lh + 2; + display.drawTextCentered(cx, y, _line2); + + drawLoadingDots(display, cx, display.height() - 2); // as the L2's + return anim ? 40 : 1000; + } + + void poll() override { + if ((int32_t)(millis() - dismiss_after) >= 0) { + _task->gotoHomeScreen(); + } + } +}; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index fdb11d16..26b35667 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -9,19 +9,7 @@ #include "../GeoUtils.h" #include "target.h" -// The CardKB background poll task (see UITask::cardkbTask) shares its I2C bus -// with the environment sensors, and some screens query those sensors while -// rendering -- exactly when that task is active. Holding this lock around a -// sensor query makes the two take turns. No-op on builds without the task. -#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) -static void* g_cardkb_bus_mutex = nullptr; // SemaphoreHandle_t, set by startCardKBCapture() -struct CardKBBusLock { - CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreTake((SemaphoreHandle_t)g_cardkb_bus_mutex, portMAX_DELAY); } - ~CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreGive((SemaphoreHandle_t)g_cardkb_bus_mutex); } -}; -#else -struct CardKBBusLock { }; -#endif +#include "CardKB.h" #ifdef WIFI_SSID #include #endif @@ -66,101 +54,7 @@ struct CardKBBusLock { }; #include "icons.h" #include "../ui-core/Lettering.h" // boot splash wordmark + lettering (shared with the L2) #include "GfxUtils.h" // gfx::drawLine — connects trail points on the Home map preview - -// Boot splash, as on the L2 (ui-lvgl/Splash.h, same lettering from -// ui-core/Lettering.h): the MeshCore wordmark rising into place, "solo" in its -// lettering once it has landed, the Solo version, upstream version + build date, and three dots -// rising in turn while the device starts. E-ink gets the still final frame. -class SplashScreen : public UIScreen { - UITask* _task; - unsigned long _start, dismiss_after; - char _solo_ver[24]; - char _line2[32]; - - // A word in the wordmark's lettering at `sc` px a pixel, top-left at (x, y). - static void drawLettering(DisplayDriver& d, const char* text, int x, int y, int gap, int sc) { - for (const char* p = text; *p; p++) { - char c = (char)tolower((unsigned char)*p); - int cw = lettering::charW(c); - if (cw < 0) continue; - for (int r = 0; r < lettering::LOGO_H; r++) - for (int col = 0; col < cw; ) { // runs of inked pixels as one rect - if (!lettering::inked(c, col, r)) { col++; continue; } - int e = col; - while (e + 1 < cw && lettering::inked(c, e + 1, r)) e++; - d.fillRect(x + col * sc, y + r * sc, (e - col + 1) * sc, sc); - col = e + 1; - } - x += cw * sc + gap; - } - } - -public: - SplashScreen(UITask* task) : _task(task) { - lettering::soloVersion(_solo_ver, sizeof(_solo_ver)); - char date[16]; - lettering::buildDate(date, sizeof(date)); -#ifdef MESHCORE_VERSION - snprintf(_line2, sizeof(_line2), "%s %s", MESHCORE_VERSION, date); -#else - snprintf(_line2, sizeof(_line2), "%s", date); -#endif - _start = millis(); - dismiss_after = _start + BOOT_SCREEN_MILLIS; - } - - int render(DisplayDriver& display) override { - const bool anim = !display.isEink(); - const unsigned long t = anim ? millis() - _start : 100000UL; // e-ink: the settled frame - display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); - const int lh = display.getLineHeight(); - const int cx = display.width() / 2; - - // The wordmark and "solo" grow in whole pixels with the panel (1x on 128 px, - // 3x on the 4.2"), as far as the block still fits over the dots. - const int dots_h = 8; - int sc = display.width() / lettering::LOGO_W; - while (sc > 1 && (lettering::LOGO_H * 2 + 5) * sc + lh * 2 + 2 > display.height() - dots_h) sc--; - if (sc < 1) sc = 1; - - // Block from the wordmark down to the second text line, centred above the dots. - const int logo_h = lettering::LOGO_H * sc; - const int block_h = logo_h * 2 + 5 * sc + lh * 2 + 2; - int top = (display.height() - dots_h - block_h) / 2; - if (top < 0) top = 0; - - // Wordmark: rises 6 px into place over the first 400 ms (ease-out). - int rise = 0; - if (t < 400) { int k = 400 - (int)t; rise = (6 * sc * k * k) / (400 * 400); } - const int lx = (display.width() - lettering::LOGO_W * sc) / 2; - for (int r = 0; r < lettering::LOGO_H; r++) - for (int col = 0; col < lettering::LOGO_W; ) { // runs of inked pixels as one rect - if (!lettering::logoBit(col, r)) { col++; continue; } - int e = col; - while (e + 1 < lettering::LOGO_W && lettering::logoBit(e + 1, r)) e++; - display.fillRect(lx + col * sc, top + rise + r * sc, (e - col + 1) * sc, sc); - col = e + 1; - } - - int y = top + logo_h + 3 * sc; - const int solo_w = lettering::textW("solo", 0) * sc + 2 * sc * 3; - if (t >= 400) drawLettering(display, "solo", cx - solo_w / 2, y, 2 * sc, sc); // once the wordmark has landed - y += logo_h + 2 * sc; - display.drawTextCentered(cx, y, _solo_ver[0] ? _solo_ver : "dev"); - y += lh + 2; - display.drawTextCentered(cx, y, _line2); - - drawLoadingDots(display, cx, display.height() - 2); // as the L2's - return anim ? 40 : 1000; - } - - void poll() override { - if ((int32_t)(millis() - dismiss_after) >= 0) { - _task->gotoHomeScreen(); - } - } -}; +#include "SplashScreen.h" static const int QUICK_MSGS_MAX = 10; @@ -216,1732 +110,7 @@ static const telemetry::Style L1_INFO = { "\xc2\xb0", true, false, 5 }; #include "../ui-core/Battery.h" -// The time on a tall portrait panel (e-ink in portrait -- height > width): HH -// and MM stacked on two lines in the huge built-in font (size 4, ~56 px tall) -// so the digits fill the narrow width. Returns the y just below it. -static int drawClockTall(DisplayDriver& d, int top_y, const struct tm* ti, bool h12) { - int hh = ti->tm_hour; - const char* ap = nullptr; - if (h12) { ap = (hh < 12) ? "AM" : "PM"; hh %= 12; if (hh == 0) hh = 12; } - const int cx = d.width() / 2; - char hbuf[4], mbuf[4]; - snprintf(hbuf, sizeof(hbuf), "%02d", hh); - snprintf(mbuf, sizeof(mbuf), "%02d", ti->tm_min); - - int y = top_y; - d.setTextSize(4); - const int lhb = d.getLineHeight(); - // The built-in GFX font advances 6 px per char but the glyph is only 5 px - // wide, so getTextWidth() over-reports by one trailing blank column and - // drawTextCentered() would bias the digits ~half a column to the left. - // Centre on the visible width (minus that trailing column) instead. - const int trail = d.getCharWidth() / 6; // one built-in column at this size - auto drawBig = [&](const char* s, int yy) { - d.setCursor(cx - ((int)d.getTextWidth(s) - trail) / 2, yy); - d.print(s); - }; - drawBig(hbuf, y); y += lhb + 2; - drawBig(mbuf, y); y += lhb + 2; - if (ap) { - d.setTextSize(2); - d.drawTextCentered(cx, y, ap); - y += d.getLineHeight() + 1; - } - d.setTextSize(1); - return y; -} - -// The big clock on wide panels (Home clock page, lock screen): HH:MM in the -// splash's slanted lettering (ui-core/Lettering.h) at `sc`, centred on cx. A -// 12-hour clock drops the leading zero, closes the gaps up and sets AM / PM -// beside the digits on their baseline -- "12:59 PM" still fits 128 px. -struct BigClock { int sc, h; }; -static BigClock bigClockSize(const DisplayDriver& d) { - int sc = d.width() / 64; - if (sc > d.height() / 32) sc = d.height() / 32; - if (sc < 1) sc = 1; - return { sc, lettering::LOGO_H * sc }; -} -// The big clock's text, gaps and AM / PM, measured: its full width (w), the -// digits' (dw). draw() puts its left edge at x. -struct BigClockText { - char t[8]; - const char* ap = nullptr; - int sc, gap, dw, w; - BigClockText(DisplayDriver& d, const struct tm* ti, bool h12, int scale) : sc(scale), gap(scale + 1) { - if (h12) { - int hh = ti->tm_hour % 12; if (hh == 0) hh = 12; - snprintf(t, sizeof(t), "%d:%02d", hh, ti->tm_min); - ap = ti->tm_hour < 12 ? "AM" : "PM"; - gap = sc / 2; - } else { - snprintf(t, sizeof(t), "%02d:%02d", ti->tm_hour, ti->tm_min); - } - dw = lettering::textW(t, 0) * sc + gap * ((int)strlen(t) - 1); - d.setTextSize(1); - w = dw + (ap ? (int)d.getTextWidth(ap) + 2 * sc : 0); - } - void draw(DisplayDriver& d, int x, int y) const { - info::lettering(d, x, y, t, sc, gap); - if (!ap) return; - d.setCursor(x + dw + 2 * sc, y + lettering::LOGO_H * sc - d.getLineHeight() + 1); - d.print(ap); - } -}; -static void drawBigClock(DisplayDriver& d, int cx, int y, const struct tm* ti, bool h12, int sc) { - BigClockText bt(d, ti, h12, sc); - bt.draw(d, cx - bt.w / 2, y); -} - -static const char* const WDAY[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; -static const char* const MON[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; - -// ── HomeScreen ──────────────────────────────────────────────────────────────── -// Forward declaration to be able to call formatDashVal from HomeScreen::render() -static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv, uint16_t low_batt_mv, - int unread, bool unread_overflow, bool imperial, CayenneLPP* lpp, bool nouns); - - -class HomeScreen : public UIScreen { - enum HomePage { - CLOCK, - FAVOURITES, - STATUS, // radio, GPS, power, mesh at a glance (took over Radio / GPS / Sensors) - BLUETOOTH, - ADVERT, - SETTINGS, - MAP, - TOOLS, - QUICK_MSG, - SHUTDOWN, - Count, // keep before LOCK — navigable-page count - LOCK // lock screen: full clock + dashboard view, not a navigable page - }; - - // Selected slot on the Favourites page (0..FAVOURITES_COUNT - 1). - uint8_t _fav_sel = 0; - - // Slot payload (pubkey prefix, or channel index in byte 0 — see the slot's - // kind), or nullptr when the slot is empty. - const uint8_t* favSlotPrefix(int slot) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p || _task->isFavouriteSlotEmpty(slot)) return nullptr; - return p->favourite_contacts[slot]; - } - - // Unpin/Replace menu for a filled tile. Choosing what to pin is the Messages - // screen's job (see UITask::pickFavouriteTarget) -- the dial doesn't carry a - // second browser for contacts, rooms and channels. - PopupMenu _tile_menu; - int _pin_target_slot = -1; - - UITask* _task; - mesh::RTCClock* _rtc; - SensorManager* _sensors; - NodePrefs* _node_prefs; - uint8_t _page; - uint8_t _prev_page; // home page restored when the device unlocks - bool _shutdown_init; - - // Sideways slide between pages (OLED; drivers that can't read their frame - // back just cut over). _slide_dir is +1 (new page from the right), -1, or 0. - static const unsigned long SLIDE_MS = 220; - int8_t _slide_dir = 0; - uint8_t _slide_from = 0; - unsigned long _slide_t0 = 0; - - // First row under the header and the page-icon row (see render()). - static int contentTop(DisplayDriver& d) { - const int pg_half = (PAGE_ICON_PX * statusIconScale(d) + 1) / 2; - return statusBarH(d) + 2 * pg_half + 4; - } - // 0..1 eased progress of the running slide, or -1 when none. - float slideProgress() { - if (!_slide_dir) return -1; - unsigned long el = millis() - _slide_t0; - if (el >= SLIDE_MS) { _slide_dir = 0; return -1; } - float t = 1.0f - (float)el / SLIDE_MS; - return 1.0f - t * t * t; // ease-out: quick start, soft landing - } - void turnPage(int dir) { - const uint8_t from = _page; - _page = navPage(_page, dir); - if (_page == from) return; - _quick_sel = 0; // a quick panel starts on its first row - DisplayDriver* d = _task->getDisplay(); - // Pages with the header slide below it; the full-screen clock slides whole. - const bool whole = from == CLOCK || _page == CLOCK; - if (d && d->slideBegin(whole ? 0 : contentTop(*d))) { - _slide_dir = (int8_t)dir; _slide_from = from; _slide_t0 = millis(); - } - } - - unsigned long _advert_sent_ms = 0; // last advert sent from this page (0: none yet) - - // ── Quick panels: an icon page as a few rows with margins round - // them, the last one opening the full screen. Up/Down picks a row, Enter - // acts on it. The rows are the Settings list's own (value, switch, bar). - enum QKind : uint8_t { Q_VALUE, Q_SWITCH, Q_BAR, Q_BADGE, Q_MORE }; - enum QAct : uint8_t { QA_NONE, QA_MORE, QA_BRIGHTNESS, QA_GPS, QA_CHAT, QA_BT, - QA_ADVERT, QA_AUTO_ADVERT, QA_TOOL, QA_LOCK, QA_HIBERNATE }; - struct QRow { - const char* label; - char value[16]; - QKind kind; - QAct act; - uint8_t n; // switch on, bar level, unread count, QA_TOOL's action - bool overflow; // unread past what the badge can count - bool is_ch; // QA_CHAT: a channel (ch) or a DM / room (prefix) - uint8_t ch; - uint8_t prefix[4]; - char name[24]; // QA_CHAT: the label's storage - }; - static const int QUICK_MAX = 8; - // How many rows a quick panel has room for (at least the 3 a 128x64 OLED - // fits): a taller screen lists more recent chats. - int quickFit() const { - DisplayDriver* d = _task->getDisplay(); - if (!d) return 3; - const int n = (d->height() - contentTop(*d) - 5) / (d->lineStep() + 1); - return n < 3 ? 3 : n > QUICK_MAX ? QUICK_MAX : n; - } - uint8_t _quick_sel = 0; - bool _quick_edit = false; // the selected row is being changed with Left/Right - bool _quick_dirty = false; // ...and has moved: saved once when it ends - static bool isQuickPage(int p) { - return p == SETTINGS || p == QUICK_MSG || p == BLUETOOTH || p == ADVERT || p == TOOLS || p == SHUTDOWN; - } - - // What a tool is doing now, for its row on the Tools panel ("" when it has - // nothing running worth telling). - void toolState(ToolsScreen::Action a, char* buf, int n) { - buf[0] = 0; - switch (a) { - case ToolsScreen::ACT_TRAIL: { - TrailStore& tr = _task->trail(); - if (tr.isActive()) geo::fmtDist(buf, n, tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true); - else strncpy(buf, "Off", n); - break; - } - case ToolsScreen::ACT_LIVESHARE: - strncpy(buf, _node_prefs && _node_prefs->loc_share_enabled ? "On" : "Off", n); break; - case ToolsScreen::ACT_LOCATOR: - strncpy(buf, _node_prefs && _node_prefs->locator_enabled ? "On" : "Off", n); break; - case ToolsScreen::ACT_REPEATER: - strncpy(buf, _node_prefs && _node_prefs->client_repeat ? "On" : "Off", n); break; - case ToolsScreen::ACT_AUTOADVERT: - strncpy(buf, AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::indexOf( - _node_prefs ? _node_prefs->advert_auto_interval_sec : 0)], n); break; - case ToolsScreen::ACT_CLOCK: - if (_task->isTimerRunning()) { - const uint32_t s = (_task->timerRemainingMs() + 999) / 1000; - if (s >= 3600) snprintf(buf, n, "%lu:%02lu:%02lu", (unsigned long)(s / 3600), (unsigned long)(s / 60 % 60), (unsigned long)(s % 60)); - else snprintf(buf, n, "%lu:%02lu", (unsigned long)(s / 60), (unsigned long)(s % 60)); - } - break; - default: break; - } - buf[n - 1] = 0; - } - - // The `max` newest conversations, DM / room or channel, newest first. - int recentChats(QRow* out, int max) { - MessageHistory& h = _task->core().history; - struct Cand { bool is_ch; uint8_t ch; uint8_t prefix[4]; uint32_t ts; } c[2 * QUICK_MAX]; - if (max > QUICK_MAX) max = QUICK_MAX; - int nc = 0; - for (int j = 0; j < h.dmHistCount() && nc < max; j++) { - const DmHistEntry& e = h.dmAtPos(h.dmHistPosNewest(j)); - bool seen = false; - for (int k = 0; k < nc; k++) if (memcmp(c[k].prefix, e.prefix, 4) == 0) seen = true; - if (seen) continue; - c[nc].is_ch = false; c[nc].ch = 0; memcpy(c[nc].prefix, e.prefix, 4); c[nc].ts = e.timestamp; nc++; - } - const int nd = nc; - for (int j = 0; j < h.chHistCount() && nc < nd + max; j++) { - const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j)); - bool seen = false; - for (int k = nd; k < nc; k++) if (c[k].ch == e.ch_idx) seen = true; - if (seen) continue; - c[nc].is_ch = true; c[nc].ch = e.ch_idx; c[nc].ts = e.timestamp; nc++; - } - for (int a = 0; a < nc; a++) // newest first - for (int b = a + 1; b < nc; b++) - if (c[b].ts > c[a].ts) { Cand t = c[a]; c[a] = c[b]; c[b] = t; } - const uint32_t now = rtc_clock.getCurrentTime(); - int n = 0; - for (int k = 0; k < nc && n < max; k++) { - QRow& r = out[n]; - memset(&r, 0, sizeof(r)); - r.act = QA_CHAT; r.is_ch = c[k].is_ch; - if (r.is_ch) { - ChannelDetails cd; - if (!the_mesh.getChannel(c[k].ch, cd) || !cd.name[0]) continue; - r.ch = c[k].ch; - snprintf(r.name, sizeof(r.name), "#%s", chanctl::bareName(cd.name)); - r.n = _task->getChannelUnread(r.ch); - r.overflow = r.n > 0 && _task->getChannelUnreadOverflow(r.ch); - } else { - ContactInfo ci; - if (!MessageHistory::contactByPrefix(c[k].prefix, ci)) continue; - memcpy(r.prefix, c[k].prefix, 4); - snprintf(r.name, sizeof(r.name), "%s", ci.name); - r.n = _task->getDMUnread(ci.id.pub_key); - r.overflow = r.n > 0 && _task->getDMUnreadOverflow(ci.id.pub_key); - } - r.label = r.name; - if (r.n > 0) r.kind = Q_BADGE; - else { r.kind = Q_VALUE; geo::fmtAgeShort(r.value, sizeof(r.value), now, c[k].ts); } - n++; - } - return n; - } - - int buildQuick(int page, QRow* rows) { - int n = 0; - auto add = [&](const char* label, QKind k, QAct a, uint8_t v) { - QRow& r = rows[n++]; - memset(&r, 0, sizeof(r)); - r.label = label; r.kind = k; r.act = a; r.n = v; - }; - // A panel with a whole screen behind it lists the way in first, its - // shortcuts after. - if (page == SETTINGS) { - add("All settings", Q_MORE, QA_MORE, 0); -#if FEAT_BRIGHTNESS_SETTING - add("Brightness", Q_BAR, QA_BRIGHTNESS, _node_prefs ? _node_prefs->display_brightness + 1 : 0); -#endif -#if ENV_INCLUDE_GPS == 1 - if (sensors.getLocationProvider()) add("GPS", Q_SWITCH, QA_GPS, _task->getGPSState() ? 1 : 0); -#endif - } else if (page == QUICK_MSG) { - add("All messages", Q_MORE, QA_MORE, 0); - n += recentChats(rows + n, quickFit() - 1); - } else if (page == BLUETOOTH) { - const bool on = _task->isSerialEnabled(); - add("Bluetooth", Q_SWITCH, QA_BT, on ? 1 : 0); - add("Phone", Q_VALUE, QA_NONE, 0); - char* v = rows[n - 1].value; - if (!on) strcpy(v, "-"); - else if (_task->isBLEConnected()) strcpy(v, "Connected"); - // The pairing PIN is BLE-specific: show it while BLE is on but not yet - // bonded. (Gating on a plain isConnected() broke this on dual builds, - // where it's hardcoded true.) - else if (the_mesh.getBLEPin() != 0) snprintf(v, sizeof(rows[0].value), "PIN %d", (int)the_mesh.getBLEPin()); - else strcpy(v, "Waiting"); - } else if (page == ADVERT) { - add("Send now", Q_VALUE, QA_ADVERT, 0); - if (_advert_sent_ms) { // when this page last sent one - const unsigned long ago = (millis() - _advert_sent_ms) / 60000UL; - char* v = rows[n - 1].value; - if (ago == 0) strcpy(v, "sent"); - else if (ago < 60) snprintf(v, sizeof(rows[0].value), "%lum ago", ago); - else snprintf(v, sizeof(rows[0].value), "%luh ago", ago / 60); - } - add("Auto", Q_VALUE, QA_AUTO_ADVERT, 0); - strcpy(rows[n - 1].value, AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::indexOf( - _node_prefs ? _node_prefs->advert_auto_interval_sec : 0)]); - } else if (page == TOOLS) { - add("All tools", Q_MORE, QA_MORE, 0); - for (int i = 0; i < ToolsScreen::RECENT_MAX; i++) { - const ToolsScreen::Action a = ToolsScreen::s_recent[i]; - add(ToolsScreen::labelOf(a), Q_VALUE, QA_TOOL, (uint8_t)a); - toolState(a, rows[n - 1].value, sizeof(rows[0].value)); - } - } else if (page == SHUTDOWN) { - add("Lock screen", Q_VALUE, QA_LOCK, 0); - add("Hibernate", Q_VALUE, QA_HIBERNATE, 0); - // The charge left, which is what you weigh before switching off. - const int mv = _task->getBattMilliVolts(); - snprintf(rows[n - 1].value, sizeof(rows[0].value), "%d%%%s", - battery::percent(mv, _node_prefs ? (int)_node_prefs->low_batt_mv : 0), - board.isExternalPowered() ? " USB" : ""); - } - return n; - } - - // Small arrows either side of x0..x0+w while Left/Right change a row. - static void drawEditArrows(DisplayDriver& d, int x0, int w, int cy, int s) { - for (int i = 0; i < 3; i++) { - d.fillRect(x0 - 6 * s + i * s, cy - i * s, s, (2 * i + 1) * s); // ◂ - d.fillRect(x0 + w + 5 * s - i * s, cy - i * s, s, (2 * i + 1) * s); // ▸ - } - } - - // The rows, from a gap below the page icons and inset from both sides. - int drawQuickPanel(DisplayDriver& d, int top, QRow* rows, int n) { - const int W = d.width(), m = W / 16, lh = d.getLineHeight(), step = d.lineStep() + 1; - const int reserve = m - 2; // so a row's right edge is W - m - if (_quick_sel >= n) _quick_sel = n - 1; - int y = top + 5, mq = 0; - for (int i = 0; i < n; i++, y += step) { - const QRow& r = rows[i]; - const bool sel = i == _quick_sel; - d.setColor(DisplayDriver::LIGHT); - if (sel) { d.fillSoftRect(m - 3, y - 1, W - 2 * m + 6, lh + 1); d.setColor(DisplayDriver::DARK); } - switch (r.kind) { - case Q_SWITCH: info::switchRow(d, y, r.label, r.n != 0, sel, reserve, m); break; - case Q_BAR: { - d.setCursor(m, y); d.print(r.label); - const int box = lh - 3, gap = 2, s = miniIconScale(d), bars = 5 * box + 4 * gap; - const bool editing = sel && _quick_edit; - const int x0 = W - m - bars - (editing ? 6 * s : 0); - if (editing) drawEditArrows(d, x0, bars, y + 1 + box / 2, s); - for (int k = 0; k < 5; k++) { - const int bx = x0 + k * (box + gap); - if (k < r.n) d.fillRect(bx, y + 1, box, box); else d.drawRect(bx, y + 1, box, box); - } - break; - } - case Q_BADGE: { - const int bw = d.unreadBadgeWidth(r.n, r.overflow); - d.drawTextEllipsized(m, y, W - 2 * m - bw - 4, r.label); - d.drawUnreadBadge(W - m, y, r.n, sel, r.overflow); - break; - } - case Q_MORE: - d.setCursor(m, y); d.print(r.label); - d.drawTextRightAlign(W - m, y, ">"); - break; - default: - if (sel && _quick_edit) { // a value being changed: arrows round it - const int s = miniIconScale(d), vw = d.getTextWidth(r.value), x0 = W - m - vw - 6 * s; - d.setCursor(m, y); d.print(r.label); - d.setCursor(x0, y); d.print(r.value); - drawEditArrows(d, x0, vw, y + 1 + (lh - 3) / 2, s); - } else { - int q = info::valueRow(d, y, r.label, r.value, sel, reserve, m); - if (q > 0) mq = q; - } - } - d.setColor(DisplayDriver::LIGHT); - } - return mq; - } - - void quickAct(int page, const QRow& r) { - switch (r.act) { - case QA_NONE: break; - case QA_MORE: - if (page == SETTINGS) _task->gotoSettingsScreen(); - else if (page == TOOLS) _task->gotoToolsScreen(); - else _task->gotoMessagesScreen(); - break; - case QA_BRIGHTNESS: - case QA_AUTO_ADVERT: _quick_edit = true; break; // Left/Right change it, Enter / Back keep it - case QA_BT: - if (_task->isSerialEnabled()) _task->disableSerial(); else _task->enableSerial(); - break; - case QA_ADVERT: - _task->notify(UIEventType::ack); - if (the_mesh.advert()) _advert_sent_ms = millis(); // the row says so - else _task->showAlert("Advert failed", 1000); - break; - case QA_TOOL: ToolsScreen::run(_task, (ToolsScreen::Action)r.n, true); break; - case QA_LOCK: _task->lockScreen(); break; - case QA_HIBERNATE: _shutdown_init = true; break; // waits for the button to be released - case QA_GPS: _task->toggleGPS(false); break; - case QA_CHAT: - if (r.is_ch) { _task->openChannelHistory(r.ch); break; } - { - ContactInfo ci; - if (!MessageHistory::contactByPrefix(r.prefix, ci)) break; - if (ci.type == ADV_TYPE_ROOM) _task->openRoomServer(ci); else _task->openContactDM(ci); - } - break; - } - } - - int pageBit(int page) const { - if (page == CLOCK) return NodePrefs::HPB_CLOCK; - if (page == FAVOURITES) return NodePrefs::HPB_FAVOURITES; - if (page == STATUS) return NodePrefs::HPB_RADIO; // the old Radio slot - if (page == BLUETOOTH) return NodePrefs::HPB_BLUETOOTH; - if (page == ADVERT) return NodePrefs::HPB_ADVERT; - if (page == TOOLS) return NodePrefs::HPB_TOOLS; - if (page == SHUTDOWN) return NodePrefs::HPB_SHUTDOWN; - if (page == MAP) return NodePrefs::HPB_MAP; - return -1; // SETTINGS, QUICK_MSG always visible (no mask bit) - } - - // Maps page_order bit-index back to the HomePage enum value for this build. - // Returns -1 if the page is not compiled in. - int bitToPage(int bit) const { - switch (bit) { - case NodePrefs::HPB_CLOCK: return CLOCK; - case NodePrefs::HPB_FAVOURITES: return FAVOURITES; - case NodePrefs::HPB_RADIO: return STATUS; - case NodePrefs::HPB_BLUETOOTH: return BLUETOOTH; - case NodePrefs::HPB_ADVERT: return ADVERT; - case NodePrefs::HPB_TOOLS: return TOOLS; - case NodePrefs::HPB_SHUTDOWN: return SHUTDOWN; - case NodePrefs::HPB_SETTINGS: return SETTINGS; - case NodePrefs::HPB_QUICK_MSG: return QUICK_MSG; - case NodePrefs::HPB_MAP: return MAP; - default: return -1; - } - } - - bool isPageVisible(int page) const { - int bit = pageBit(page); - if (bit < 0) return true; - uint16_t mask = (_node_prefs && _node_prefs->home_pages_mask) ? _node_prefs->home_pages_mask : NodePrefs::HP_ALL; - // Status stands in for the Radio, GPS and Sensors pages: shown if any was. - if (page == STATUS) return (mask & (NodePrefs::HP_RADIO | NodePrefs::HP_GPS | NodePrefs::HP_SENSORS)) != 0; - return (mask >> bit) & 1; - } - - // Build ordered list of all visible pages, respecting page_order when set. - // Returns count; out[] receives HomePage enum values. - int buildVisibleOrder(int* out) const { - int n = 0; - bool custom = _node_prefs && _node_prefs->page_order_set == NodePrefs::PAGE_ORDER_MAGIC; - if (custom) { - for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) { - uint8_t v = _node_prefs->page_order[i]; - if (v < 1 || v > NodePrefs::HPB_COUNT) break; - int pg = bitToPage(v - 1); - if (pg >= 0 && pg < (int)Count && isPageVisible(pg)) out[n++] = pg; - } - // Append any visible page missing from page_order (handles corrupted/migrated prefs) - for (int pg = 0; pg < (int)Count; pg++) { - if (!isPageVisible(pg)) continue; - bool found = false; - for (int i = 0; i < n; i++) if (out[i] == pg) { found = true; break; } - if (!found) out[n++] = pg; - } - } else { - for (int pg = 0; pg < (int)Count; pg++) - if (isPageVisible(pg)) out[n++] = pg; - } - return n; - } - - int navPage(int from, int dir) const { - int order[(int)Count]; int n = buildVisibleOrder(order); - if (n == 0) return from; - int cur = 0; - for (int i = 0; i < n; i++) if (order[i] == from) { cur = i; break; } - return order[((cur + dir) % n + n) % n]; - } - - // reserve_left: how much width from x=0 must stay clear of status icons -- - // the time / node name on the pages (name_min below); the lock page passes - // 0, its clock sits under the bar. -1 = use the normal name reserve. - int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int reserve_left = -1) { - int low_mv = _node_prefs ? (int)_node_prefs->low_batt_mv : 0; - int pct = battery::percent((int)batteryMilliVolts, low_mv); - - uint8_t mode = battery::mode(_node_prefs ? _node_prefs->batt_display_mode : 0); - - display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); - - const int lh = display.getLineHeight(); - const int cw = display.getCharWidth(); - const int sis = statusIconScale(display); // STATUS_ICON_SCALE, or the font's - const int bar_h = statusBarH(display); - const int ind = STATUS_ICONS_SCALED ? 7 * sis + 2 : cw + 2; // indicator slot width - const int ind_h = (bar_h > lh) ? bar_h - 2 : (display.isSingleFont() ? lh - 2 : lh); - const int text_y = (bar_h > lh) ? (bar_h - lh) / 2 : 0; // % / V text centred in a tall bar - const int ind_gap = display.pixelScale() > 1 ? 3 : 1; // gap between indicator boxes - - int battLeftX; - if (mode == battery::PERCENT) { - char buf[6]; - snprintf(buf, sizeof(buf),"%d%%", pct); - battLeftX = display.width() - display.getTextWidth(buf) - 1; - display.setCursor(battLeftX, text_y); - display.print(buf); - } else if (mode == battery::VOLTAGE) { - char buf[8]; - snprintf(buf, sizeof(buf),"%u.%02uV", batteryMilliVolts / 1000, (batteryMilliVolts % 1000) / 10); - battLeftX = display.width() - display.getTextWidth(buf) - 1; - display.setCursor(battLeftX, text_y); - display.print(buf); - } else { // icon — scales with lh, same box height as the status icons beside it (ind_h) - const int iconH = ind_h; - const int iconW = (bar_h > lh) ? ind_h * 2 : lh * 2; - const int bm = display.pixelScale() > 1 ? 3 : 2; // inner margin: 3px on a doubled panel, else 2px - battLeftX = display.width() - iconW - 3; - // The outline with its corner pixels left out, softly rounded. - display.fillRect(battLeftX + 1, 0, iconW - 2, 1); - display.fillRect(battLeftX + 1, iconH - 1, iconW - 2, 1); - display.fillRect(battLeftX, 1, 1, iconH - 2); - display.fillRect(battLeftX + iconW - 1, 1, 1, iconH - 2); - // Nub height/2, vertically centred by remaining-space/2 rather than a flat - // iconH/4 margin — the flat form only centres when iconH is a multiple of - // 4 (true for the old built-in font's lh=8, false for misc-fixed's 7/9), - // so it visibly drifted off-centre once the box height changed. - const int nub_h = iconH / 2; - display.fillRect(battLeftX + iconW, (iconH - nub_h) / 2, 2, nub_h); - int fillW = (pct * (iconW - 2 * bm)) / 100; - display.fillRect(battLeftX + bm, bm, fillW, iconH - 2 * bm); - } - - // Secondary status icons, laid out right→left in PRIORITY order so a crowded - // bar sheds its least-important cues instead of crushing the time. Once an - // icon won't fit above the reserved area, every lower-priority icon after - // it is dropped too (the list is ordered high→low). - // - // Priority: power > BT > GPS fix > alarm > mute > auto-advert > trail > live-share > - // repeater. Battery (drawn above) is always rightmost. The background modes - // (advert / trail / live-share / repeater) stay outside any BT gate — they - // keep running with Bluetooth off, so their cue must not vanish with it. - LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr; - bool gps_on = loc && _node_prefs && _node_prefs->gps_enabled; - bool mute_on = false; -#ifdef PIN_BUZZER - mute_on = _task->isBuzzerQuiet(); -#endif - // Plain glyphs, no blinking: each one shows a state by its shape. - // Bluetooth only once a phone is connected (it's on nearly always; the - // Bluetooth page tells the rest), GPS as a broken reticle until a fix. - const bool gps_fix = gps_on && loc->isValid(); - struct Sicon { bool active; const MiniIcon* icon; }; - const Sicon icons[] = { - { board.isExternalPowered(), &ICON_CHARGE }, // USB power: next to the battery - { _task->isSerialEnabled() && _task->isBLEConnected(), &ICON_BLUETOOTH }, - { gps_on, gps_fix ? &ICON_GPS : &ICON_GPS_SEARCH }, - { _node_prefs && _node_prefs->alarm_on, &ICON_ALARM }, - { mute_on, &ICON_MUTE }, - { _node_prefs && _node_prefs->advert_auto_interval_sec > 0, &ICON_ADVERT }, - { _task->trail().isActive(), &ICON_TRAIL }, - { _node_prefs && _node_prefs->loc_share_enabled, &ICON_MAP_CONTACT }, - { _node_prefs && _node_prefs->client_repeat, &ICON_REPEATER }, - }; - - int x = battLeftX; - const int name_min = (reserve_left >= 0) ? reserve_left : display.getCharWidth() * 5; - // Settings > Message alert Compact: the envelope and its count, first by the - // battery; its place is kept through the blink so nothing beside it jumps. - if (const int n = _task->msgBadgeCount()) { - const int ix = x - mailCountWidth(display, n) - ind_gap - 2 * miniIconScale(display); // the icons' boxes pad theirs - if (ix >= name_min) { - if (_task->msgBadgeLit()) drawMailCount(display, ix, 0, ind_h, n); - _task->msgBadgeInBar(); - x = ix; - } - } - for (const Sicon& s : icons) { - if (!s.active) continue; - int ix = x - ind - ind_gap; - if (ix < name_min) break; // out of room — drop this + all lower priority - if (STATUS_ICONS_SCALED) drawSlotIconScaled(display, ix, ind, ind_h, *s.icon, sis); - else drawSlotIcon(display, ix, ind, ind_h, *s.icon); - x = ix; - } - return x; - } - - CayenneLPP sensors_lpp; - int sensors_nb = 0; - unsigned long next_sensors_refresh = 0; // 0: read on the next call - - void refresh_sensors() { - if (!next_sensors_refresh || (int32_t)(millis() - next_sensors_refresh) >= 0) { - sensors_lpp.reset(); - sensors_nb = 0; - sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); - { CardKBBusLock bus_lock; sensors.querySensors(0xFF, sensors_lpp); } - LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize()); - uint8_t channel, type; - while(reader.readHeader(channel, type)) { - reader.skipData(type); - sensors_nb ++; - } -#if AUTO_OFF_MILLIS > 0 - next_sensors_refresh = (millis() + 5000) | 1; // refresh sensor values every 5 sec -#else - next_sensors_refresh = (millis() + 60000) | 1; // refresh sensor values every 1 min -#endif - } - } - -public: - HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs) - : _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0), - _prev_page(0), _shutdown_init(false), sensors_lpp(200) { } - - // Flips home screen between LOCK page and the page that was showing before locking - void setLocked(bool locked) { - if (locked) { - if (_page != LOCK) _prev_page = _page; - _page = LOCK; - } else { - _page = _prev_page; - } - } - - void poll() override { - if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released - // _shutdown_init is never cleared elsewhere -- on real hardware - // that's harmless because _board->powerOff() halts the MCU, so - // there is no next poll() tick to matter. In the sim, powerOff() - // is a deliberate no-op (no real hardware to power off, see - // SimMainBoard.h), so without this the instance keeps running and - // this branch re-fires shutdown() -> _display->turnOff() on every - // single tick forever, repeatedly blacking out its canvas -- which - // fights with a freshly reset instance's own boot splash trying to - // render onto that same (simInstanceTag-keyed) canvas element. - _shutdown_init = false; - _task->shutdown(); - } - } - - // Compact map preview for the Home "Map" page: own position, the GPS trail, - // and live-tracked contacts (◆) folded into one auto-scaled box. A simplified - // cousin of TrailScreen's map (no grid/labels, no break markers) so the home - // carousel stays light. Returns false (and draws nothing) when there's - // nothing to show. - bool drawMapPreview(DisplayDriver& display, int ax, int ay, int aw, int ah) { - if (aw < 8 || ah < 8) return false; - bool init = false; - int32_t mnla = 0, mxla = 0, mnlo = 0, mxlo = 0; - auto fold = [&](int32_t la, int32_t lo) { - if (!init) { mnla = mxla = la; mnlo = mxlo = lo; init = true; } - else { if (la < mnla) mnla = la; if (la > mxla) mxla = la; - if (lo < mnlo) mnlo = lo; if (lo > mxlo) mxlo = lo; } - }; - TrailStore& tr = _task->trail(); - if (!tr.empty()) { int32_t a, b, c, d; tr.boundingBox(a, b, c, d); fold(a, b); fold(c, d); } - LiveTrackStore& lt = _task->liveTrack(); - uint32_t now = rtc_clock.getCurrentTime(); - for (int i = 0; i < LiveTrackStore::CAPACITY; i++) - if (lt.isActive(i, now)) fold(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6); - int32_t mla, mlo; - bool have_gps = _task->currentLocation(mla, mlo); - if (have_gps) fold(mla, mlo); - int32_t tla, tlo; - bool have_tgt = _task->activeTargetPos(tla, tlo); // active Locator/Nav target - if (have_tgt) fold(tla, tlo); - if (!init) return false; - - // North marker — top-right, the same mini-icon as the full Trail map. - display.setColor(DisplayDriver::LIGHT); - { - const int ns = miniIconScale(display); - miniIconDrawTop(display, ax + aw - ICON_MAP_NORTH.w * ns - 1, ay + 1, ICON_MAP_NORTH); - } - - int cx = ax + aw / 2, cy = ay + ah / 2; - // Degenerate: one coincident point — just centre the markers. - if (mnla == mxla && mnlo == mxlo) { - for (int i = 0; i < LiveTrackStore::CAPACITY; i++) - if (lt.isActive(i, now)) { miniIconDrawCentered(display, cx, cy, ICON_MAP_CONTACT); break; } - if (have_gps || !tr.empty()) miniIconDrawCentered(display, cx, cy, ICON_MAP_CURRENT); - if (have_tgt) miniIconDrawCentered(display, cx, cy, ICON_MAP_TARGET); // highlight on top - return true; - } - float avg_lat_rad = ((mnla + mxla) / 2.0e6f) * (float)M_PI / 180.0f; - float lon_scale = cosf(avg_lat_rad); if (lon_scale < 0.05f) lon_scale = 0.05f; - float lat_span = (float)(mxla - mnla); - float lon_span = (float)(mxlo - mnlo) * lon_scale; - float slat = (float)ah / (lat_span > 0 ? lat_span : 1.0f); - float slon = (float)aw / (lon_span > 0 ? lon_span : 1.0f); - float scale = (slat < slon) ? slat : slon; - int off_x = ax + (aw - (int)(lon_span * scale)) / 2; - int off_y = ay + (ah - (int)(lat_span * scale)) / 2; - auto project = [&](int32_t la, int32_t lo, int& px, int& py) { - px = off_x + (int)((float)(lo - mnlo) * lon_scale * scale); - py = off_y + (int)((float)(mxla - la) * scale); - }; - // Trail as a connected line, matching the full Trail map (shared helper — - // see gfx::drawTrail); no break marker here, just a silent gap. - gfx::drawTrail(display, tr, project, [](int, int, int, int) {}); - for (int i = 0; i < LiveTrackStore::CAPACITY; i++) { - if (!lt.isActive(i, now)) continue; - int px, py; project(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6, px, py); - miniIconDrawCentered(display, px, py, ICON_MAP_CONTACT); - } - if (have_gps) { int px, py; project(mla, mlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_CURRENT); } - // Active target flag drawn last so it stays legible even atop a contact/own dot. - if (have_tgt) { int px, py; project(tla, tlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_TARGET); } - - // Bottom-left scale reference, always shown — distance to the active - // target (or else the nearest live-tracked contact) now lives on the - // status line below instead (see statusDistanceKm() / render()), so this - // corner is free for it. - { - display.setColor(DisplayDriver::LIGHT); - int ty = ay + ah - display.getLineHeight(); - static const float M_PER_1E6 = 0.11132f; // metres per 1e-6° lat - float ppm = scale / M_PER_1E6; // pixels per metre - if (ppm > 0.0f) { - bool imp = _task->useImperial(); - static const float MET_M[] = { 5,10,25,50,100,250,500,1000,2000,5000,10000,25000,50000 }; - static const char* MET_L[] = { "5m","10m","25m","50m","100m","250m","500m","1km","2km","5km","10km","25km","50km" }; - static const float IMP_M[] = { 4.572f,15.24f,30.48f,76.2f,152.4f,402.34f,804.67f,1609.34f,4828.0f,16093.4f,80467.2f }; - static const char* IMP_L[] = { "15ft","50ft","100ft","250ft","500ft","1/4mi","1/2mi","1mi","3mi","10mi","50mi" }; - const float* M = imp ? IMP_M : MET_M; - const char* const* L = imp ? IMP_L : MET_L; - int N = imp ? (int)(sizeof(IMP_M) / sizeof(IMP_M[0])) : (int)(sizeof(MET_M) / sizeof(MET_M[0])); - float target = 8.0f / ppm; // short reference tick, not 1/3 of the width - int sel = 0; - for (int i = N - 1; i >= 0; i--) if (M[i] <= target) { sel = i; break; } - int barpx = (int)(M[sel] * ppm + 0.5f); - if (barpx < 5) barpx = 5; - if (barpx > aw / 4) barpx = aw / 4; - int bx = ax + 1, mid = ty + display.getLineHeight() / 2; - display.fillRect(bx, mid, barpx, 1); // single tick, on the text baseline - display.setCursor(bx + barpx + 2, ty); - display.print(L[sel]); - } - } - return true; - } - - // Distance shown on the MAP status line. The active Locator/Nav target - // takes priority — that's what the flag on the mini-map is pointing at, - // and it's the only way a waypoint target ever gets a distance readout - // here (a waypoint isn't a live-tracked contact). Falls back to the - // nearest live-tracked ([LOC]-sharing) contact when no target is set. - // -1 when we don't have a fix or nothing to measure against. - float statusDistanceKm() { - int32_t mla, mlo; - if (!_task->currentLocation(mla, mlo)) return -1.0f; - int32_t tla, tlo; - if (_task->activeTargetPos(tla, tlo)) return geo::haversineKm(mla, mlo, tla, tlo); - LiveTrackStore& lt = _task->liveTrack(); - uint32_t now = rtc_clock.getCurrentTime(); - float nearest_km = -1.0f; - for (int i = 0; i < LiveTrackStore::CAPACITY; i++) { - if (!lt.isActive(i, now)) continue; - float d = geo::haversineKm(mla, mlo, lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6); - if (nearest_km < 0.0f || d < nearest_km) nearest_km = d; - } - return nearest_km; - } - - // The Map page: the mini-map in a soft frame on the left, what it shows - // spelled out in a column on the right (GPS, the trail, live contacts or - // the target). Without a position the column still says what's missing. - // A tall screen (portrait e-ink) stacks them: the map, the four rows under it. - void drawMapPage(DisplayDriver& d, int top) { - const int W = d.width(), H = d.height(), lh = d.getLineHeight(); - const int s = miniIconScale(d); - const bool tall = H - top > W; - // A screen 40 characters wide (the 4.2"): a square map and a column of - // labelled rows beside it, one line apart. - const bool wide = !tall && W >= 40 * d.getCharWidth(); - const int rows_h = tall ? 4 * (lh + lh / 2) : 0; - const int ph = H - top - rows_h; - const int pw = tall ? W : wide ? (ph < W * 3 / 5 ? ph : W * 3 / 5) : W / 2; // map panel - d.setColor(DisplayDriver::LIGHT); - d.drawSoftRect(0, top, pw, ph); - if (!drawMapPreview(d, 2, top + 2, pw - 4, ph - 4)) { - // An empty sheet: a sparse dot grid with the reason over it. - for (int gy = top + 4; gy < top + ph - 2; gy += 6 * s) - for (int gx = 4; gx < pw - 2; gx += 6 * s) d.fillRect(gx, gy, s, s); - const char* why = "No fix"; - const int tw = d.getTextWidth(why), tx = (pw - tw) / 2, ty = top + (ph - lh) / 2; - d.setColor(DisplayDriver::DARK); - d.fillRect(tx - 2, ty - 1, tw + 4, lh + 2); - d.setColor(DisplayDriver::LIGHT); - d.setCursor(tx, ty); - d.print(why); - } - - if (wide) { drawMapColumn(d, pw + 6, top, W - 2); return; } - - // The column: four rows, always, like the Status tiles. - const int cx = tall ? 2 : pw + 3, cw = W - cx; - const int row_h = tall ? rows_h / 4 : ph / 4; - int y = (tall ? top + ph : top) + (row_h - lh) / 2 + 1; - auto line = [&](const MiniIcon& ic, const char* text) { - miniIconDraw(d, cx, y, ic); - const int tx = cx + ic.w * s + 2 * s; - d.drawTextEllipsized(tx, y, cx + cw - tx, text); - y += row_h; - }; - char buf[20]; -#if ENV_INCLUDE_GPS == 1 - LocationProvider* loc = sensors.getLocationProvider(); - const bool on = _task->getGPSState(); - if (!loc) strcpy(buf, "No GPS"); - else if (!on) strcpy(buf, "Off"); - else if (!loc->isValid()) strcpy(buf, "No fix"); - else snprintf(buf, sizeof(buf), "%ld sats", loc->satellitesCount()); -#else - strcpy(buf, "No GPS"); -#endif - line(ICON_GPS, buf); - TrailStore& tr = _task->trail(); - if (tr.empty() && !tr.isActive()) strcpy(buf, "No trail"); - else geo::fmtDist(buf, sizeof(buf), tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true); - line(ICON_TRAIL, buf); - const float km = statusDistanceKm(); - int32_t tla, tlo; - const bool target = _task->activeTargetPos(tla, tlo); - const int live = _task->liveTrack().active(rtc_clock.getCurrentTime()); - if (live > 0 && km >= 0 && !target) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); - else snprintf(buf, sizeof(buf), "%d live", live); - line(ICON_MAP_CONTACT, buf); - if (target) { - if (km >= 0) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); - else strcpy(buf, "-"); - line(ICON_MAP_TARGET, buf); - } else { - snprintf(buf, sizeof(buf), "%d wpts", _task->waypoints().count()); - line(ICON_MAP_WAYPOINT, buf); - } - } - - // The wide Map page's column, x0..x1 from top: GPS and the position, the - // trail, then live contacts, the target and waypoints, each an icon, a - // label and the value at the right, the groups split by a dotted rule. - void drawMapColumn(DisplayDriver& d, int x0, int top, int x1) { - const int lh = d.getLineHeight(), s = miniIconScale(d), step = d.lineStep() + 2; - const int lx = x0 + PAGE_ICON_PX * s + 2 * s; // labels clear of the widest icon - int y = top + 1; - auto row = [&](const MiniIcon* ic, const char* label, const char* val) { - if (ic) miniIconDraw(d, x0, y + (lh - ic->h * s) / 2 - s, *ic); - d.setCursor(lx, y); d.print(label); - d.drawTextRightAlign(x1, y, val); - y += step; - }; - auto rule = [&]() { info::rule(d, x0, y + lh / 2 - 2, x1 - x0); y += lh / 2 + 2; }; - char buf[24]; -#if ENV_INCLUDE_GPS == 1 - LocationProvider* loc = sensors.getLocationProvider(); - const bool on = _task->getGPSState(); - if (!loc) strcpy(buf, "none"); - else if (!on) strcpy(buf, "off"); - else if (!loc->isValid()) strcpy(buf, "no fix"); - else snprintf(buf, sizeof(buf), "%ld sats", loc->satellitesCount()); - row(&ICON_GPS, "GPS", buf); - int32_t mla, mlo; - if (_task->currentLocation(mla, mlo)) { - snprintf(buf, sizeof(buf), "%.5f", mla / 1e6); row(nullptr, "Lat", buf); - snprintf(buf, sizeof(buf), "%.5f", mlo / 1e6); row(nullptr, "Lon", buf); - } -#else - row(&ICON_GPS, "GPS", "none"); -#endif - rule(); - TrailStore& tr = _task->trail(); - if (tr.empty() && !tr.isActive()) strcpy(buf, "-"); - else geo::fmtDist(buf, sizeof(buf), tr.totalDistanceMeters() / 1000.0f, _task->useImperial(), true); - row(&ICON_TRAIL, "Trail", buf); - if (!tr.empty() || tr.isActive()) { - const uint32_t t = tr.elapsedSeconds(); - snprintf(buf, sizeof(buf), "%luh %02lum", (unsigned long)(t / 3600), (unsigned long)(t / 60 % 60)); - row(nullptr, tr.isActive() ? (tr.isPaused() ? "Paused" : "Rec") : "Time", buf); - snprintf(buf, sizeof(buf), "%d", tr.count()); - row(nullptr, "Points", buf); - } - rule(); - const float km = statusDistanceKm(); - int32_t tla, tlo; - const bool target = _task->activeTargetPos(tla, tlo); - const int live = _task->liveTrack().active(rtc_clock.getCurrentTime()); - snprintf(buf, sizeof(buf), "%d", live); - row(&ICON_MAP_CONTACT, "Live", buf); - if (live > 0 && km >= 0 && !target) { - geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); - row(nullptr, "Nearest", buf); - } - if (target) { - if (km >= 0) geo::fmtDist(buf, sizeof(buf), km, _task->useImperial(), true); - else strcpy(buf, "-"); - row(&ICON_MAP_TARGET, "Target", buf); - } - snprintf(buf, sizeof(buf), "%d", _task->waypoints().count()); - row(&ICON_MAP_WAYPOINT, "Waypoints", buf); - } - - // The Favourites grid: landscape 3x2, portrait 2x3. - static int favCols(DisplayDriver& d) { return d.isLandscape() ? 3 : 2; } - - // The clock fields set in Settings (dashboard_fields), in order; their count. - int clockFields(uint8_t* out) { - int n = 0; - if (_node_prefs) - for (int k = 0; k < 3; k++) - if (_node_prefs->dashboard_fields[k] != telemetry::NONE && _node_prefs->dashboard_fields[k] < telemetry::COUNT) - out[n++] = _node_prefs->dashboard_fields[k]; - return n; - } - // One field's value without its noun (the label beside it names it); "-" when there is none. - void clockFieldValue(uint8_t f, char* val, int n) { - formatDashVal(f, val, n, _task->getBattMilliVolts(), _node_prefs->low_batt_mv, - _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(), - _task->getAnyUnreadOverflow(), _node_prefs->units_imperial, &sensors_lpp, false); - if (!val[0]) snprintf(val, n, "-"); - } - - // ── Clock page: the big clock, a seconds bar under it, the date, and the - // clock fields (Settings) as columns along the bottom, name over value. - // Without fields the clock and date sit in the middle. - // 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) 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) { - struct tm ti; - localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti); - const bool h12 = _node_prefs && _node_prefs->clock_12h; - const bool show_sec = !Features::IS_EINK && (!_node_prefs || !_node_prefs->clock_hide_seconds); - const int W = d.width(), H = d.height(); - d.setColor(DisplayDriver::LIGHT); - d.setTextSize(1); - const int lh = d.getLineHeight(); - - uint8_t fields[3]; - const int nf = clockFields(fields); - - // The month under the date, where it fits: a tall screen (portrait - // e-ink), or the 4.2" landscape one with the clock moved to the top. - const int fields_h = nf ? 2 * lh + 1 + lh / 2 : 0; - const int cal_h = calendar::height(d, ti); - bool cal = false; - int date_y; - if (d.height() > d.width()) { // portrait e-ink: stacked digits - date_y = drawClockTall(d, lh / 2, &ti, h12); - cal = true; - } else { - const BigClock bc = bigClockSize(d); - const int bar = 3 * bc.sc / 2; // the seconds bar and its gaps - const int block = bc.h + bar + 1 + lh; - cal = bc.sc + block + lh / 2 + 2 + cal_h <= H - fields_h; - const int y = nf || cal ? bc.sc : (H - block) / 2; - drawBigClock(d, W / 2, y, &ti, h12, bc.sc); - const int bar_y = y + bc.h + bar / 2 + 1; - if (show_sec) { // fills over the minute: dotted track, solid part - const int x0 = W / 8, bw = W - 2 * x0, fill = bw * ti.tm_sec / 60; - d.fillRect(x0, bar_y, fill, 1); - for (int x = x0 + fill + (fill & 1); x < x0 + bw; x += 2) d.fillRect(x, bar_y, 1, 1); - } - // Alarm armed: the bell at the bar's left end (the status bar, which - // shows it everywhere else, is hidden on this page). - if (_node_prefs && _node_prefs->alarm_on) miniIconDrawTop(d, W / 32, bar_y - 2, ICON_ALARM); - date_y = y + bc.h + bar + 1; - } - char buf[24]; - snprintf(buf, sizeof(buf), "%s %d %s %d", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], 1900 + ti.tm_year); - d.drawTextCentered(W / 2, date_y, buf); - - const int cal_top = date_y + lh + lh / 2 + 2; - if (cal && cal_h <= H - fields_h - cal_top) { // no wider than four characters a day - const int cw = W < 28 * d.getCharWidth() ? W : 28 * d.getCharWidth(); - calendar::draw(d, (W - cw) / 2, cal_top, cw, ti, alarmDays(ti)); - } - - if (nf == 0) return; - refresh_sensors(); - const int val_y = H - lh, lab_y = val_y - lh - 1, cw = W / nf; - for (int k = 0; k < nf; k++) { - char val[20]; - clockFieldValue(fields[k], val, sizeof(val)); - const int x0 = k * cw; - auto cell = [&](int yy, const char* t) { - if ((int)d.getTextWidth(t) <= cw - 2) d.drawTextCentered(x0 + cw / 2, yy, t); - else d.drawTextEllipsized(x0 + 1, yy, cw - 2, t); - }; - cell(lab_y, telemetry::LABEL[fields[k]]); - cell(val_y, val); - } - } - - // ── Lock screen: the status bar (drawn by render()), then one of two - // looks (Settings > Display > Lock clock): - // big -- the big clock, the date, unread messages in a pill; - // compact -- a small clock with the date beside it, the clock fields as - // rows below (plus unread messages when no field shows them). - // A key press on the lit screen (not the one that wakes it, so a glance - // shows the data) puts the unlock hint in a pill at the bottom for a few - // seconds (UITask::lockHintShown); e-ink keeps it up, it has no glance. - void drawLockPage(DisplayDriver& d) { - const int W = d.width(), H = d.height(); - d.setColor(DisplayDriver::LIGHT); - d.setTextSize(1); - const int lh = d.getLineHeight(), top = statusBarH(d) + 3; - const bool hint = Features::IS_EINK || _task->lockHintShown(); - const int pill_y = H - lh - 3; // the bottom pill, clear of what's above - const int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); - const uint32_t unix_ts = _rtc->getCurrentTime(); - const bool synced = unix_ts >= 1000000000UL; - struct tm ti; - if (synced) localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti); - const bool h12 = _node_prefs && _node_prefs->clock_12h; - const bool compact = _node_prefs && _node_prefs->lock_compact && W > H; - // The month and the rows under the big clock: on a tall screen (portrait - // e-ink), and on one 40 characters wide (the 4.2"). - const bool extras = H > W || W >= 40 * d.getCharWidth(); - - char pill[24] = ""; - if (compact) { - const int m = W / 16, x0 = W / 32; - int sc = bigClockSize(d).sc / 2; - if (sc < 1) sc = 1; - const int ch = lettering::LOGO_H * sc; - if (!synced) { - d.setCursor(x0, top + (ch - lh + 1) / 2); - d.print("No time sync"); - } else { // the date beside the clock, without the weekday if it won't fit - BigClockText bt(d, &ti, h12, sc); - bt.draw(d, x0, top); - char date[16]; - snprintf(date, sizeof(date), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]); - if (x0 + bt.w + lh / 2 + (int)d.getTextWidth(date) > W - x0) - snprintf(date, sizeof(date), "%d %s", ti.tm_mday, MON[ti.tm_mon]); - d.drawTextRightAlign(W - x0, top + (ch - lh + 1) / 2, date); - } - uint8_t rows[4]; - int nr = clockFields(rows); - bool has_msgs = false; - for (int k = 0; k < nr; k++) if (rows[k] == telemetry::MSGS) has_msgs = true; - if (!has_msgs && unread > 0) rows[nr++] = telemetry::MSGS; - if (nr) refresh_sensors(); - const int step = d.lineStep(); - for (int k = 0, y = top + ch + 6; k < nr && y + lh <= H; k++, y += step) { - if (hint && y + lh >= pill_y) break; // the hint pill takes the bottom - char val[20]; - clockFieldValue(rows[k], val, sizeof(val)); - info::valueRow(d, y, telemetry::LABEL[rows[k]], val, false, m - 2, m); - } - } else { - if (!synced) { - d.drawTextCentered(W / 2, H / 2 - lh, "No time sync"); - } else { - int date_y; - if (H > W) { - date_y = drawClockTall(d, top, &ti, h12); - } else { - const BigClock bc = bigClockSize(d); - drawBigClock(d, W / 2, top, &ti, h12, bc.sc); - date_y = top + bc.h + 3; - } - char buf[16]; - snprintf(buf, sizeof(buf), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]); - d.drawTextCentered(W / 2, date_y, buf); - if (extras) drawLockTallExtras(d, date_y + lh + lh / 2 + 2, hint ? pill_y - 3 : H, ti, unread); - } - if (!hint && unread > 0 && !(synced && extras)) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); - } - - if (hint) { - const int n = _task->lockSeqCount(); -#if defined(CARDKB_I2C) - const char* first = _task->hasCardKB() ? "Back+3xEnter/Fn+Esc" : "Hold Back + 3xEnter"; -#else - const char* first = "Hold Back + 3xEnter"; -#endif - snprintf(pill, sizeof(pill), "%s", n == 0 ? first : n == 1 ? "Enter x2 more..." : "Enter x1 more..."); - } - if (!pill[0]) return; - const int pw = d.getTextWidth(pill) + 6; - d.fillSoftRect((W - pw) / 2, pill_y, pw, lh + 3); - d.setColor(DisplayDriver::DARK); - d.drawTextCentered(W / 2, pill_y + 2, pill); - d.setColor(DisplayDriver::LIGHT); - } - - // Under the lock clock on a tall or a 4.2" screen, between y and bottom: the month, - // then a row each for unread messages, the armed alarm and the clock fields - // -- the rows first, the calendar only if there's room for it as well. - void drawLockTallExtras(DisplayDriver& d, int y, int bottom, const struct tm& ti, int unread) { - const int W = d.width(), m = W / 16, step = d.lineStep(), s = miniIconScale(d); - struct Row { const MiniIcon* ic; const char* label; char value[20]; } rows[6]; - int n = 0; - auto add = [&](const MiniIcon* ic, const char* label) -> char* { - rows[n].ic = ic; rows[n].label = label; rows[n].value[0] = 0; - return rows[n++].value; - }; - if (unread > 0) - snprintf(add(&ICON_PG_MSG, "Messages"), sizeof(rows[0].value), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); - if (_node_prefs && _node_prefs->alarm_on) - snprintf(add(&ICON_ALARM, "Alarm"), sizeof(rows[0].value), "%02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min); - uint8_t fields[3]; - const int nf = clockFields(fields); - if (nf) refresh_sensors(); - for (int k = 0; k < nf && n < 6; k++) { - if (fields[k] == telemetry::MSGS && unread > 0) continue; // already a row - clockFieldValue(fields[k], add(nullptr, telemetry::LABEL[fields[k]]), sizeof(rows[0].value)); - } - const int rows_h = n * step; - if (calendar::height(d, ti) + d.getLineHeight() / 2 + rows_h <= bottom - y) { - const int cw = W < 28 * d.getCharWidth() ? W : 28 * d.getCharWidth(); // as on the Clock page - calendar::draw(d, (W - cw) / 2, y, cw, ti, alarmDays(ti)); - y += calendar::height(d, ti) + d.getLineHeight() / 2; - } - for (int k = 0; k < n && y + d.getLineHeight() <= bottom; k++, y += step) { - int x = m; - if (rows[k].ic) { miniIconDraw(d, x, y, *rows[k].ic); x += rows[k].ic->w * s + 2 * s; } - info::valueRow(d, y, rows[k].label, rows[k].value, false, m - 2, x); - } - } - - // The Status page: four tiles split by dotted rules, each one subject's - // headline (icon + main value) over a detail line. A tile with the room - // (the 4.2" panel) adds a line and a chart of its history under it. Enter - // opens the Status screen with all of it. - void drawStatusTiles(DisplayDriver& d, int top) { - const int W = d.width(), H = d.height() - top; - const int lh = d.getLineHeight(), s = miniIconScale(d); - const int mid_x = W / 2, mid_y = top + H / 2; - const bool roomy = H / 2 >= 7 * lh; // three lines and a chart a tile - StatusScreen* st = (StatusScreen*)_task->statusScreen(); - d.setColor(DisplayDriver::LIGHT); - info::vrule(d, mid_x, top, H); - info::rule(d, 0, mid_y, W); - // The tile's box, inside the rules. - auto box = [&](int col, int row, int& x, int& y, int& w, int& h) { - x = col ? mid_x + 3 : 1; y = row ? mid_y + 2 : top + 1; - w = col ? W - x : mid_x - 2 - x; - h = (row ? top + H : mid_y - 1) - y; - }; - auto tile = [&](int col, int row, const MiniIcon* ic, float batt, const char* head, const char* detail, - const char* more = "") { - int x, y, w, h; - box(col, row, x, y, w, h); - const int iy = y + (lh - info::batteryH(d)) / 2 - s; - int tx = x; - if (ic) { miniIconDraw(d, x, iy, *ic); tx = x + ic->w * s + 2 * s; } - else if (batt >= 0) { info::battery(d, x, iy, batt); tx = x + info::batteryW(d) + 2 * s; } - d.drawTextEllipsized(tx, y, x + w - tx, head); - d.drawTextEllipsized(x, y + lh + 2, w, detail); - if (roomy && more[0]) d.drawTextEllipsized(x, y + 2 * (lh + 2), w, more); - }; - // Under a roomy tile's three lines: a history. - auto tileChart = [&](int col, int row, const info::History& hs, int min_span, bool bars) { - int x, y, w, h; - box(col, row, x, y, w, h); - const int cy = y + 3 * (lh + 2) + 2, ch = y + h - cy - 3; - if (ch >= 2 * lh) info::chart(d, x, cy, w - 4, ch, hs, min_span, bars, nullptr, nullptr); - }; - char h[20], t[20], m[28]; - - snprintf(h, sizeof(h), "%.3f", _node_prefs->freq); - snprintf(t, sizeof(t), "SF%u %gk", (unsigned)_node_prefs->sf, _node_prefs->bw); - const int nf = (int)radio_driver.getNoiseFloor(); - if (nf) snprintf(m, sizeof(m), "Noise %d dBm", nf); else m[0] = 0; - tile(0, 0, &ICON_PG_RADIO, -1, h, t, m); - if (roomy && st) tileChart(0, 0, st->noiseHistory(), 6, false); - -#if ENV_INCLUDE_GPS == 1 - { - LocationProvider* loc = sensors.getLocationProvider(); - const bool on = _task->getGPSState(); - t[0] = 0; - if (!loc) strcpy(h, "No GPS"); - else if (!on) strcpy(h, "GPS off"); - else if (!loc->isValid()) strcpy(h, "No fix"); - else { -#ifdef STATUS_HAS_SKY - GpsSky* g = skyview::sky(); - strcpy(h, g && g->fix_mode == 2 ? "2D fix" : "3D fix"); -#else - strcpy(h, "Fix"); -#endif - } - if (loc && on) snprintf(t, sizeof(t), "%ld sats", loc->satellitesCount()); - int32_t la, lo; - m[0] = 0; - if (loc && on && loc->isValid() && _task->currentLocation(la, lo)) snprintf(m, sizeof(m), "%.4f %.4f", la / 1e6, lo / 1e6); - tile(1, 0, &ICON_GPS, -1, h, t, m); - } -#else - tile(1, 0, &ICON_GPS, -1, "No GPS", ""); -#endif - - const int mv = _task->getBattMilliVolts(); - const int pct = battery::percent(mv, _node_prefs ? (int)_node_prefs->low_batt_mv : 0); - snprintf(h, sizeof(h), "%d%%", pct); - snprintf(t, sizeof(t), "%d.%02d V%s", mv / 1000, (mv % 1000) / 10, board.isExternalPowered() ? " USB" : ""); - tile(0, 1, nullptr, pct / 100.0f, h, t); - if (st && roomy) tileChart(0, 1, st->battHistory(), 20, false); - else if (st) { // the battery's trend beside its % - const int x0 = 1 + info::batteryW(d) + 2 * s + d.getTextWidth(h) + 3 * s, x1 = mid_x - 3; - if (x1 - x0 >= 12) info::spark(d, x0, mid_y + 2, x1 - x0, lh - 1, st->battHistory(), 20); - } - - const int nc = the_mesh.getNumContacts(); - snprintf(h, sizeof(h), "%d node%s", nc, nc == 1 ? "" : "s"); - snprintf(t, sizeof(t), "%d in 1 h", StatusScreen::heardLastHour()); - snprintf(m, sizeof(m), "RX %lu TX %lu", (unsigned long)StatusScreen::totalRx(), (unsigned long)StatusScreen::totalTx()); - tile(1, 1, &ICON_MAP_CONTACT, -1, h, t, m); - if (roomy && st) tileChart(1, 1, st->trafficHistory(), 4, true); - } - - // Small 5x5 glyph shown in the page-indicator row for each HomePage. - static const MiniIcon* pageIcon(int page) { - switch (page) { - case CLOCK: return &ICON_PG_CLOCK; - case FAVOURITES: return &ICON_PG_STAR; - case STATUS: return &ICON_CHART; - case BLUETOOTH: return &ICON_PG_BT; - case ADVERT: return &ICON_PG_ADVERT; - case SETTINGS: return &ICON_PG_SETTINGS; - case MAP: return &ICON_PG_MAP; - case TOOLS: return &ICON_PG_TOOLS; - case QUICK_MSG: return &ICON_PG_MSG; - case SHUTDOWN: return &ICON_PG_POWER; - } - return nullptr; - } - - int render(DisplayDriver& display) override { - char tmp[80]; - int mq_delay = 0; // >0 while a selected row's name is marquee-scrolling - int anim_ms = 0; // >0 while something animates: a page turn - display.setTextSize(1); - const int lh = display.getLineHeight(); // line height at sz1 - const int step = display.lineStep(); // lh + 2 - // Page-indicator row: small page icons (PAGE_ICON_PX) replace the old dots. Centre and - // gap scale with the font so the band clears the header above and content - // below (identical to the old lh+4 / +6 dots layout at 1x). - const int pg_half = (PAGE_ICON_PX * statusIconScale(display) + 1) / 2; // page icons follow STATUS_ICON_SCALE - const int dots_y = statusBarH(display) + pg_half + 1; // icon-row centre, below the header - const int content_y = contentTop(display); // first content row, below the icons - const float slide = slideProgress(); // -1, or how far a page turn has got - - // Title bar displaying node name (except on lock screen), status icons and battery. - // Hidden on fullscreen pages (CLOCK). - if (_page != CLOCK) { - display.setColor(DisplayDriver::LIGHT); - // The lock page has nothing on the left of the bar (its clock sits - // below it), so the status icons may use the whole row. - const int lock_reserve = _page == LOCK ? 0 : -1; - int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts(), lock_reserve); - display.setColor(DisplayDriver::LIGHT); - - if (_page != LOCK) { - const int bar_ty = (statusBarH(display) - lh) / 2; // text centred in a tall bar (STATUS_ICON_SCALE) - // The time, once the clock is set (the node name before that): the - // name is what others see, the time is what you glance at here. - char filtered_name[sizeof(_node_prefs->node_name)]; - uint32_t now_ts = _rtc->getCurrentTime(); - if (now_ts >= 1000000000UL) { - struct tm lt; - localTm(now_ts, _node_prefs->tz_offset_hours, lt); - int hh = lt.tm_hour; - if (_node_prefs->clock_12h) { hh %= 12; if (hh == 0) hh = 12; } - snprintf(filtered_name, sizeof(filtered_name), "%d:%02d", hh, lt.tm_min); - if (display.width() >= 40 * display.getCharWidth()) // the 4.2": the date beside it - snprintf(filtered_name + strlen(filtered_name), sizeof(filtered_name) - strlen(filtered_name), - " %s %d %s", WDAY[lt.tm_wday], lt.tm_mday, MON[lt.tm_mon]); - } else { - display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); - } - - // Only show the live-power readout when APC is actually controlling power — - // not merely when the pref is set. While repeating APC is suppressed and - // power is pinned to the ceiling, so apcActive() is false and the name bar - // drops the readout (matching the "--" lock in Settings). - if (the_mesh.apcActive()) { - char pwr_buf[8]; - snprintf(pwr_buf, sizeof(pwr_buf), "%ddB", (int)radio_driver.getTxPower()); - int pwr_w = display.getTextWidth(pwr_buf); - display.drawTextEllipsized(0, bar_ty, rightEdge - 2 - pwr_w - 2, filtered_name); - display.drawTextRightAlign(rightEdge - 2, bar_ty, pwr_buf); - } else { - display.drawTextEllipsized(0, bar_ty, rightEdge - 2, filtered_name); - } - } - } - - // ensure current page is visible (e.g. after settings change) - if (!isPageVisible(_page)) _page = navPage(_page, +1); - - // curr page indicator — a row of small page icons, one per visible page, with - // the current page underlined. Hidden on CLOCK and LOCK (full screen used for - // the clock/dashboard). - if (_page != CLOCK && _page != LOCK) { - int order[(int)Count]; int n = buildVisibleOrder(order); - int curr_vis = 0; - for (int i = 0; i < n; i++) if (order[i] == _page) { curr_vis = i; break; } - const int s = statusIconScale(display); // STATUS_ICON_SCALE, or the font's - const int icon_w = PAGE_ICON_PX * s; - int pitch = icon_w + 5 * s; // comfortable spacing - if (n > 1) { // shrink to fit if many pages - int fit = (display.width() - icon_w) / (n - 1); - if (fit < pitch) pitch = fit; - } - int x = display.width() / 2 - pitch * (n - 1) / 2; - // The current page: its icon knocked out of a soft pill. During a page - // turn the pill glides over from the page being left. - int pw = icon_w + 4; - if (pw > pitch - 1) pw = pitch - 1; - int pill_x = x + pitch * curr_vis; - if (slide >= 0) { - for (int i = 0; i < n; i++) - if (order[i] == _slide_from) { pill_x = x + pitch * i + (int)((pill_x - x - pitch * i) * slide + 0.5f); break; } - } - display.setColor(DisplayDriver::LIGHT); - display.fillSoftRect(pill_x - pw / 2, dots_y - pg_half - 1, pw, pg_half * 2 + 3); - for (int i = 0; i < n; i++) { - const MiniIcon* ic = pageIcon(order[i]); - const int d2 = 2 * (x - pill_x < 0 ? pill_x - x : x - pill_x); - if (d2 <= pw - icon_w) display.setColor(DisplayDriver::DARK); // inside the pill - else if (d2 < pw + icon_w) { x += pitch; continue; } // half under the gliding pill - else display.setColor(DisplayDriver::LIGHT); - if (ic) miniIconDrawCenteredScaled(display, x, dots_y, *ic, s); - x += pitch; - } - display.setColor(DisplayDriver::LIGHT); - } - - if (_page == HomePage::CLOCK) { - uint32_t unix_ts = _rtc->getCurrentTime(); - if (unix_ts < 1000000000UL) { - display.setColor(DisplayDriver::LIGHT); - display.setTextSize(1); - int mid_y = display.height() / 2 - step; - display.drawTextCentered(display.width() / 2, mid_y, "! No time sync"); - display.drawTextCentered(display.width() / 2, mid_y + step, "Enable GPS or"); - display.drawTextCentered(display.width() / 2, mid_y + step * 2, "connect app"); - } else { - drawClockPage(display, unix_ts); - } - } else if (_page == HomePage::LOCK) { - drawLockPage(display); - } else if (_page == HomePage::STATUS) { - drawStatusTiles(display, content_y); - } else if (isQuickPage(_page) && !(_page == HomePage::SHUTDOWN && _shutdown_init)) { - display.setColor(DisplayDriver::LIGHT); - display.setTextSize(1); - QRow rows[QUICK_MAX]; - const int n = buildQuick(_page, rows); - int q = drawQuickPanel(display, content_y, rows, n); - if (_page == HomePage::TOOLS && _task->isTimerRunning()) q = 1000; // the timer's countdown - if (q > 0 && (mq_delay <= 0 || q < mq_delay)) mq_delay = q; - } else if (_page == HomePage::MAP) { - drawMapPage(display, content_y + 4); // the gap under the page icons - } else if (_page == HomePage::FAVOURITES) { - // Grid of pinned contacts. Layout transposes to current orientation: - // landscape → 3×2, portrait → 2×3. The selected card is filled. - // No title — node name + battery (top bar) and the page-dots indicator above - // serve as the page identity. - display.setColor(DisplayDriver::LIGHT); - display.setTextSize(1); - - const int cols = favCols(display); - const int rows = NodePrefs::FAVOURITES_COUNT / cols; - const int grid_y = content_y + 4; // the gap under the page icons, as on every page - const int grid_h = display.height() - grid_y; - const int cell_w = display.width() / cols; - const int cell_h = grid_h / rows; - const int line_h = display.getLineHeight(); - - if (_fav_sel >= NodePrefs::FAVOURITES_COUNT) _fav_sel = 0; - - bool fav_changed = false; // a stale (gone) slot was pruned this pass → persist once after the loop - for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) { - int row = i / cols; - int col = i % cols; - int cx = col * cell_w; - int cy = grid_y + row * cell_h; - bool sel = (i == _fav_sel); - const uint8_t* prefix = favSlotPrefix(i); - // Each slot a card: a soft frame round a pinned one, a dotted outline - // round a free one, filled while selected. - const int tx = cx + 1, ty = cy, tw = cell_w - 2, th = cell_h - 2; - display.setColor(DisplayDriver::LIGHT); - if (sel) { display.fillSoftRect(tx, ty, tw, th); display.setColor(DisplayDriver::DARK); } - else if (prefix) display.drawSoftRect(tx, ty, tw, th); - else { - for (int x = tx + 2; x < tx + tw - 1; x += 2) { display.fillRect(x, ty, 1, 1); display.fillRect(x, ty + th - 1, 1, 1); } - for (int y = ty + 2; y < ty + th - 1; y += 2) { display.fillRect(tx, y, 1, 1); display.fillRect(tx + tw - 1, y, 1, 1); } - } - - char name[26]; - uint8_t unread = 0; - bool overflow = false; - bool resolved = false; - uint32_t last_ts = 0; // heard from / last post, for a tall card - int32_t plat = 0, plon = 0; // a contact's advertised position - const char* last_text = nullptr; // the newest message, for a big card - bool last_out = false; - - if (prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL) { - uint8_t ch_idx = prefix[0]; - ChannelDetails ch; - if (the_mesh.getChannel(ch_idx, ch) && ch.name[0]) { - // '#' marks a channel apart from a contact tile — the two share the - // grid and Enter does something different on each. - name[0] = '#'; - display.translateUTF8ToBlocks(name + 1, chanctl::bareName(ch.name), sizeof(name) - 1); - unread = _task->getChannelUnread(ch_idx); - overflow = unread > 0 && _task->getChannelUnreadOverflow(ch_idx); - resolved = true; - MessageHistory& h = _task->core().history; - for (int j = 0; j < h.chHistCount(); j++) { - const ChHistEntry& e = h.chAtPos(h.chHistPosNewest(j)); - if (e.ch_idx == ch_idx) { last_ts = e.timestamp; last_text = e.text; break; } - } - } - } else if (prefix) { - for (int idx = 0; ; idx++) { - ContactInfo c; - if (!the_mesh.getContactByIdx(idx, c)) break; - if (memcmp(c.id.pub_key, prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { - display.translateUTF8ToBlocks(name, c.name, sizeof(name)); - unread = _task->getDMUnread(c.id.pub_key); - overflow = unread > 0 && _task->getDMUnreadOverflow(c.id.pub_key); - resolved = true; - last_ts = c.lastmod; - plat = c.gps_lat; plon = c.gps_lon; - MessageHistory& h = _task->core().history; - const int rp = h.dmHistEntryForContact(c.id.pub_key, 0); - if (rp >= 0) { last_text = h.dmAtPos(rp).text; last_out = h.dmAtPos(rp).outgoing; } - break; - } - } - } - - if (prefix && !resolved) { - // The pinned target is gone — prefs outlived the contact/channel list - // (e.g. a wiped /contacts3; onContactRemoved/onChannelRemoved only - // catch a live delete). Clear the stale slot so it renders as an empty - // "+" tile instead of a blank one. Persisted once after the loop. - _task->clearFavouriteSlot(i); - fav_changed = true; - } - - if (resolved) { - // Reserve space for the unread badge so the name's ellipsis lands - // before it instead of underneath. Badge and name share one baseline. - int bw = unread > 0 ? display.unreadBadgeWidth(unread, overflow) + 3 : 0; // badge + 3 px gap - // A tall card (e-ink) has the name at the top and lines of detail - // under it: when it was last heard (a channel: its last post) and, - // for a contact with a position, how far away it is. - const bool tall = th >= 3 * line_h + 6; - int name_y = tall ? ty + 3 : ty + (th - line_h) / 2; - int name_max_w = tw - 6 - bw; - if (name_max_w < 6) name_max_w = 6; - int r = display.drawTextEllipsized(tx + 3, name_y, name_max_w, name, sel); - if (sel && r > 0) mq_delay = r; - if (unread > 0) - display.drawUnreadBadge(tx + tw - 3, name_y, unread, sel, overflow); - // A card six lines tall on a screen 40 characters wide (the 4.2"): - // the newest message under the name, how long ago and how far along - // the bottom. - if (th >= 6 * line_h && display.width() >= 40 * display.getCharWidth()) { - display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); - const int by = ty + th - line_h - 3; // the bottom line - const uint32_t now = rtc_clock.getCurrentTime(); - char buf[16]; - if (last_ts) { - geo::fmtAgeShort(buf, sizeof(buf), now, last_ts); - display.setCursor(tx + 3, by); display.print(buf); - } - int32_t mla, mlo; - if ((plat || plon) && _task->currentLocation(mla, mlo)) { - geo::fmtDist(buf, sizeof(buf), geo::haversineKm(mla, mlo, plat, plon), _task->useImperial(), true); - display.drawTextRightAlign(tx + tw - 3, by, buf); - } - const int my = name_y + line_h + line_h / 3; // the message, wrapped - const int nl_max = (by - 2 - my) / line_h; - if (nl_max > 0) { - char pv[MSG_TEXT_BUF + 4], tr_pv[MSG_TEXT_BUF + 4]; - if (last_text) snprintf(pv, sizeof(pv), "%s%s", last_out ? "Me: " : "", last_text); - else snprintf(pv, sizeof(pv), "No messages"); - display.translateUTF8ToBlocks(tr_pv, pv, sizeof(tr_pv)); - char lines[5][FS_CHARS_MAX]; - const int cap = nl_max < 4 ? nl_max : 4; - int n = FullscreenMsgView::wrapLines(display, tr_pv, tw - 6, lines, cap + 1); - if (n > cap) { // more than fits: the last line shown ends in an ellipsis - n = cap; - const size_t l = strlen(lines[n - 1]); - if (l + 4 < sizeof(lines[0])) strcat(lines[n - 1], " ..."); - } - for (int k = 0; k < n; k++) display.drawTextEllipsized(tx + 3, my + k * line_h, tw - 6, lines[k]); - } - } else if (tall) { - display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); // the badge leaves its own - const int s = miniIconScale(display), gap = line_h / 3; - int y = name_y + line_h + gap; - auto detail = [&](const MiniIcon& ic, const char* text) { - if (y + line_h > ty + th - 2) return; - miniIconDraw(display, tx + 3, y, ic); - const int x = tx + 3 + ic.w * s + 2 * s; - display.drawTextEllipsized(x, y, tx + tw - 3 - x, text); - y += line_h + gap; - }; - char buf[16]; - const uint32_t now = rtc_clock.getCurrentTime(); - if (last_ts) { - geo::fmtAgeShort(buf, sizeof(buf), now, last_ts); - detail(prefix && _task->favouriteSlotKind(i) == NodePrefs::FAV_KIND_CHANNEL ? ICON_PG_MSG : ICON_PG_CLOCK, buf); - } - int32_t mla, mlo; - if ((plat || plon) && _task->currentLocation(mla, mlo)) { - // The distance after an arrow towards it (north up). - geo::fmtDist(buf, sizeof(buf), geo::haversineKm(mla, mlo, plat, plon), _task->useImperial(), true); - detail(*ICON_ARROWS[((geo::bearingDeg(mla, mlo, plat, plon) + 22) % 360) / 45], buf); - } - } - } else { - display.drawTextCentered(tx + tw / 2, ty + (th - line_h) / 2, "+"); - } - if (sel) display.setColor(DisplayDriver::LIGHT); - } - // Persist any pruned slots once, outside the loop — a render pass can clear - // several gone tiles but only one flash write is needed. Self-healing: once - // cleared, the slot is empty next frame so this can't re-fire per frame. - if (fav_changed) the_mesh.savePrefs(); - if (_tile_menu.active) _tile_menu.render(display); - } else if (_page == HomePage::SHUTDOWN) { - display.setColor(DisplayDriver::LIGHT); - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, content_y + step, "hibernating..."); // the panel's Hibernate row - } - if (slide >= 0) { // page turn: the page left behind slides out, this one in - display.slideCompose(_slide_dir * (int)(display.width() * slide + 0.5f)); - anim_ms = 15; - } - if (Features::IS_EINK) { - // slow display: poll every 30 s; inbound msgs force immediate refresh via notify() - return (mq_delay > 0 && mq_delay < Features::HOME_REFRESH_MS) ? mq_delay : Features::HOME_REFRESH_MS; - } - if (_page == HomePage::CLOCK) { - bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; - int ret = show_sec ? 1000 : 60000; - if (anim_ms > 0 && anim_ms < ret) ret = anim_ms; - return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; - } - int ret = 5000; - if (anim_ms > 0 && anim_ms < ret) ret = anim_ms; - return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; - } - - bool handleInput(char c) override { - // Favourites grid claims joystick UP/DOWN and inner LEFT/RIGHT; LEFT at the - // left column and RIGHT at the right column fall through to page nav so the - // user can still leave the page sideways. - if (_page == HomePage::FAVOURITES) { - // The tile menu consumes all input while open. - if (_tile_menu.active) { - auto res = _tile_menu.handleInput(c); - if (res == PopupMenu::SELECTED && _pin_target_slot >= 0) { - if (_tile_menu.selectedIndex() == 1) { // Replace - _task->pickFavouriteTarget(_pin_target_slot); - _pin_target_slot = -1; - return true; - } - _task->clearFavouriteSlot(_pin_target_slot); - the_mesh.savePrefs(); - } - if (res != PopupMenu::NONE) _pin_target_slot = -1; - return true; - } - DisplayDriver* d = _task->getDisplay(); - const int cols = d ? favCols(*d) : 2; - const int rows = NodePrefs::FAVOURITES_COUNT / cols; - int col = _fav_sel % cols; - int row = _fav_sel / cols; - if ((c == KEY_LEFT || c == KEY_PREV) && col > 0) { _fav_sel--; return true; } - if ((c == KEY_RIGHT || c == KEY_NEXT) && col < cols - 1) { _fav_sel++; return true; } - if (c == KEY_UP && row > 0) { _fav_sel -= cols; return true; } - if (c == KEY_DOWN && row < rows - 1) { _fav_sel += cols; return true; } - if (c == KEY_CONTEXT_MENU) { - // Filled tile → Unpin / Replace. An empty tile has nothing to offer: - // its Enter already opens the picker. - if (favSlotPrefix(_fav_sel)) { - _pin_target_slot = _fav_sel; - _tile_menu.begin("Slot options", 2); - _tile_menu.addItem("Unpin"); - _tile_menu.addItem("Replace"); - } - return true; - } - if (c == KEY_ENTER) { - // Filled slot → open its conversation, empty slot → in-place pin picker. - const uint8_t* pfx = favSlotPrefix(_fav_sel); - if (!pfx) { _task->pickFavouriteTarget(_fav_sel); return true; } - if (_task->favouriteSlotKind(_fav_sel) == NodePrefs::FAV_KIND_CHANNEL) { - _task->openChannelHistory(pfx[0]); - return true; - } - for (int idx = 0; ; idx++) { - ContactInfo c2; - if (!the_mesh.getContactByIdx(idx, c2)) break; - if (memcmp(c2.id.pub_key, pfx, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { - // A room opens through its own entry point: posting to one needs a - // login handshake that a plain DM view would skip. - if (c2.type == ADV_TYPE_ROOM) _task->openRoomServer(c2); - else _task->openContactDM(c2); - return true; - } - } - _task->showAlert("Contact not found", 800); - return true; - } - // Edge LEFT/RIGHT and unhandled keys fall through to page nav below. - } - - if (isQuickPage(_page) && _quick_edit) { - // Changing a bar: shown live, saved once on the way out. - if ((c == KEY_LEFT || c == KEY_RIGHT) && _node_prefs) { - QRow rows[QUICK_MAX]; - const int n = buildQuick(_page, rows); - const QAct act = n > 0 ? rows[_quick_sel < n ? _quick_sel : n - 1].act : QA_NONE; - const int d = c == KEY_RIGHT ? 1 : -1; - if (act == QA_BRIGHTNESS) { - uint8_t& b = _node_prefs->display_brightness; - const uint8_t nb = (uint8_t)constrain((int)b + d, 0, 4); - if (nb != b) { b = nb; _quick_dirty = true; _task->applyDisplayPrefs(); } - } else if (act == QA_AUTO_ADVERT) { - const int i = constrain(AutoAdvertScreen::indexOf(_node_prefs->advert_auto_interval_sec) + d, - 0, AutoAdvertScreen::OPT_COUNT - 1); - if (_node_prefs->advert_auto_interval_sec != AutoAdvertScreen::OPTS[i]) { - _node_prefs->advert_auto_interval_sec = AutoAdvertScreen::OPTS[i]; - _quick_dirty = true; - } - } - return true; - } - if (c == KEY_ENTER || c == KEY_CANCEL) { - _quick_edit = false; - _task->savePrefsIfDirty(_quick_dirty); - _quick_dirty = false; - return true; - } - return true; // nothing else while editing - } - if (isQuickPage(_page)) { - QRow rows[QUICK_MAX]; - const int n = buildQuick(_page, rows); - if (c == KEY_UP) { if (_quick_sel > 0) _quick_sel--; return true; } - if (c == KEY_DOWN) { if (_quick_sel + 1 < n) _quick_sel++; return true; } - if (c == KEY_ENTER && n > 0) { quickAct(_page, rows[_quick_sel < n ? _quick_sel : n - 1]); return true; } - } - if (c == KEY_LEFT || c == KEY_PREV) { - turnPage(-1); - return true; - } - if (c == KEY_NEXT || c == KEY_RIGHT) { - turnPage(+1); - return true; - } - if (c == KEY_ENTER && _page == HomePage::STATUS) { - _task->gotoStatusScreen(StatusScreen::TAB_RADIO); - return true; - } - if (c == KEY_ENTER && _page == HomePage::MAP) { - _task->gotoMapScreen(); - 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; - } - if (c == KEY_CONTEXT_MENU && _page == HomePage::MAP) { - _task->quickShareMyLocation(); - return true; - } - return false; - } -}; +#include "HomeScreen.h" void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { @@ -2721,38 +890,6 @@ bool UITask::isButtonPressed() const { #endif } -// A dashboard field in this display's font (ui-core/Telemetry.h); `nouns` -// when it stands without its label (the lock screen). "" for none. -static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv, uint16_t low_batt_mv, - int unread, bool unread_overflow, bool imperial, CayenneLPP* lpp, bool nouns) { - val[0] = '\0'; - if (field == telemetry::NONE || field >= telemetry::COUNT) return; - telemetry::Style st = L1_TELEMETRY; - st.nouns = nouns; - if (nouns) st.gps_dp = 2; // no label: room for the position is shorter - telemetry::Inputs in; - in.batt_mv = batt_mv; - in.low_batt_mv = low_batt_mv; - in.nodes = the_mesh.getNumContacts(); - in.unread = unread; - in.unread_more = unread_overflow; - in.imperial = imperial; -#if ENV_INCLUDE_GPS == 1 - in.loc = sensors.getLocationProvider(); - { NodePrefs* np = the_mesh.getNodePrefs(); in.gps_on = np && np->gps_enabled; } // off: no stale satellite count -#endif - if (telemetry::isSensor(field)) { - if (!lpp) { - static CayenneLPP s_lpp(200); s_lpp.reset(); - { CardKBBusLock bus_lock; sensors.querySensors(0xFF, s_lpp); } - lpp = &s_lpp; - } - in.lpp = lpp->getBuffer(); - in.lpp_len = lpp->getSize(); - } - telemetry::text(field, in, st, val, val_len); -} - void UITask::enqueueKey(char c) { if (c == 0) return; uint8_t next = (_kq_head + 1) % KEY_QUEUE_SIZE; @@ -2861,219 +998,6 @@ bool UITask::dequeueKey(char& c) { return true; } -#if defined(CARDKB_I2C) -// CardKB's "fn" column (key_map in M5Stack's unit_CardKB.cpp): Fn+ sends 0x80 + that key's row index, entirely disjoint from every other -// code this UI recognises. Indexed by (raw - 0x80); non-letter slots (digits, -// arrows, enter, tab, bs, space -- handled separately or unused) are 0. -static const char CARDKB_FN_BASE[48] = { - 0,0,0,0,0,0,0,0,0,0,0,0,0, // esc,1-0,bs,tab - 'q','w','e','r','t','y','u','i','o','p', 0, 0,0, // q-p, (unused), LEFT,UP - 'a','s','d','f','g','h','j','k','l', 0, 0,0, // a-l, enter, DOWN,RIGHT - 'z','x','c','v','b','n','m', 0,0,0, // z-m, comma,period,space -}; -#endif - -// Poll an optional CardKB (I2C keyboard, addr 0x5F) on CARDKB_I2C, feeding -// the same key queue as every physical button. Most of its output needs no -// translation at all: CardKB's own arrow/Enter/Esc byte codes are already -// identical to this UI's KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL (0xB4-0xB7, 13, -// 27), and Backspace (0x08) / printable ASCII (0x20-0x7E) collide with -// nothing that existed before. Plain Enter/arrows act like the physical -// centre button/joystick (grid commit/navigate) -- except in Compact mode's -// plain grid state (see below), which is designed to need no joystick at all. -// Tab (0x09, otherwise unused) is the Hold-Enter equivalent everywhere, -// including the ~30 non-keyboard Hold-Enter menus (message reply/navigate, -// Bot/Admin/Repeater, ...) and inside the on-screen keyboard itself (shift- -// lock, clear-all, accent popup on whatever cell is selected) -- it used to -// need a separate Fn+Tab for the latter, but that was pure redundancy: plain -// Tab already covered every case Fn+Tab did, just not while the keyboard was -// showing, so the carve-out was dropped instead of the shortcut. In Compact -// mode's plain grid state Tab means something more useful instead (opens the -// placeholder picker directly -- see below). Fn still gives two other clean, -// stateless modifiers: -// - Fn+Enter (0xA3) submits the field (KEY_KB_ENTER) without needing to -// navigate to the special row's DONE cell. The placeholder/accent popups -// are modal and consume it first (dismiss them with Enter/Esc), same as -// they consume every other key. -// - Fn+ opens the accent popup for that base letter directly -// (KeyboardWidget::openAccentFor()) -- no arrow-hunting across the grid. -// CardKB is level-triggered (it keeps returning the held key's byte, not just -// once), so _cardkb_last_raw debounces it into one press per physical -// keypress, same as a MomentaryButton's CLICK event. -void UITask::pollCardKB() { -#if defined(CARDKB_I2C) - if (!_has_cardkb) return; - // No artificial throttle: unlike a MomentaryButton (BUTTON_USE_INTERRUPTS - // latches every edge in an ISR ring buffer, so it survives a blocking e-ink - // refresh untouched), CardKB is plain I2C polling with no interrupt line on - // the Grove cable and no onboard queue -- it only ever reports "what's held - // right now". A press that starts and fully releases while curr->render() - // is blocked is physically unobservable, no software fix can recover it. - // Polling every loop() iteration (same as a digital button's check(), which - // has no throttle either) just shrinks that miss window down to exactly the - // render() duration instead of render()+30ms. - // First, keys the background task captured while the display blocked, in order. - while (_cardkb_q_tail != _cardkb_q_head) { - uint8_t q = _cardkb_q[_cardkb_q_tail]; - _cardkb_q_tail = (uint8_t)((_cardkb_q_tail + 1) % CARDKB_Q_LEN); - handleCardKBByte(q); - } - uint8_t raw; - if (!readCardKBEdge(raw)) return; - handleCardKBByte(raw); -#endif -} - -#if defined(CARDKB_I2C) -bool UITask::readCardKBEdge(uint8_t& raw) { - CARDKB_I2C.requestFrom(0x5F, 1); - if (!CARDKB_I2C.available()) return false; - raw = CARDKB_I2C.read(); - if (raw == _cardkb_last_raw) return false; // still held (or still released) -- no new edge - _cardkb_last_raw = raw; - return raw != 0; // 0 = key just released, nothing to enqueue -} - -// Single producer (the poll task) / single consumer (pollCardKB() in the -// loop) ring -- byte-sized indices, so no locking needed. -void UITask::cardkbQueuePush(uint8_t raw) { - uint8_t next = (uint8_t)((_cardkb_q_head + 1) % CARDKB_Q_LEN); - if (next == _cardkb_q_tail) return; // full: drop (16 keys within one refresh) - _cardkb_q[_cardkb_q_head] = raw; - _cardkb_q_head = next; -} - -#if defined(NRF52_PLATFORM) -void UITask::cardkbTask(void* self) { - UITask* t = (UITask*)self; - SemaphoreHandle_t m = (SemaphoreHandle_t)t->_cardkb_mutex; - for (;;) { - if (t->_cardkb_bg_active && xSemaphoreTake(m, portMAX_DELAY) == pdTRUE) { - if (t->_cardkb_bg_active) { // re-check: loop may have just finished - uint8_t raw; - if (t->readCardKBEdge(raw)) t->cardkbQueuePush(raw); - } - xSemaphoreGive(m); - } - vTaskDelay(ms2tick(10)); // a keypress lasts ~50+ ms, so 10 ms never misses one - } -} -#endif -#endif - -// Keep sampling the CardKB while the display blocks the loop, so keys typed -// during a (long, on big panels) refresh aren't lost. Called LAST in begin(): -// the heap is tight on large-panel builds, and every screen is allocated with -// `new` -- a failed allocation leaves that screen silently unopenable. Creating -// the optional poll task afterwards means a short heap costs the task (the -// CardKB is then only read between frames, as without it), never a screen. -void UITask::startCardKBCapture() { -#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) - if (!_has_cardkb) return; - SemaphoreHandle_t m = xSemaphoreCreateMutex(); - if (!m) return; - // Publish the mutex BEFORE creating the task: at higher priority it starts - // running inside xTaskCreate() and reads _cardkb_mutex straight away. - _cardkb_mutex = (void*)m; - // TASK_PRIO_NORMAL (2) preempts the loop task (TASK_PRIO_LOW, 1); BLE runs higher. - if (xTaskCreate(&UITask::cardkbTask, "cardkb", 512, this, TASK_PRIO_NORMAL, NULL) == pdPASS) { - g_cardkb_bus_mutex = m; // shared with CardKBBusLock (sensor queries) - return; - } - _cardkb_mutex = nullptr; // no task -> setCardKBBackground() stays a no-op - vSemaphoreDelete(m); -#endif -} - -// Hand CardKB sampling to the background task (on = loop is about to block on -// the display) or take it back (off). Taking the mutex on the way out waits for -// any in-flight background I2C read to finish before the loop touches the bus. -void UITask::setCardKBBackground(bool on) { -#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) - if (!_cardkb_mutex) return; - if (on) { - _cardkb_bg_active = true; - } else { - SemaphoreHandle_t m = (SemaphoreHandle_t)_cardkb_mutex; - xSemaphoreTake(m, portMAX_DELAY); - _cardkb_bg_active = false; - xSemaphoreGive(m); - } -#else - (void)on; -#endif -} - -void UITask::handleCardKBByte(uint8_t raw) { -#if defined(CARDKB_I2C) - - // Compact mode (Settings > Keyboard's "Ext. KB" row) hides the letter grid - // entirely, and is meant to guarantee joystick-free operation: while it's - // the active surface (KeyboardWidget::inPlainGridState() -- showing, no - // popup open, not already mid cursor-move) arrows drive the text cursor - // directly instead of a grid selection nobody could see anyway, and plain - // Tab opens the placeholder picker directly instead of the row/col-dependent - // Hold-Enter dispatch (which would be meaningless here -- row/col are never - // deliberately navigated to in this mode). Cursor mode / the accent / - // placeholder popups all render their own visible feedback regardless of - // Compact, so none of this applies once inPlainGridState() is false -- - // arrows/Tab fall through to their normal meaning there (e.g. arrows drive - // the placeholder/accent popup's own selection). - bool compact_grid = _node_prefs && _node_prefs->keyboard_cardkb_compact && _kb.inPlainGridState(); - - char key; - if (raw == 0xA3) { // Fn+Enter -- submit the field - key = KEY_KB_ENTER; - } else if (compact_grid && (raw == (uint8_t)KEY_LEFT || raw == (uint8_t)KEY_UP || - raw == (uint8_t)KEY_DOWN || raw == (uint8_t)KEY_RIGHT)) { - char woke = checkDisplayOn((char)raw); // already sets _next_refresh=0 when the display was on - if (woke && !_locked) _kb.moveCursorDirect((char)raw); - return; - } else if (raw == 0x09) { // Tab -- Hold-Enter equivalent, always (single shortcut: there used to - if (compact_grid) { // also be a separate Fn+Tab for this, but plain Tab already covers every - char woke = checkDisplayOn((char)raw); // case Fn+Tab did -- outside the keyboard, and now inside it - if (woke && !_locked) _kb.openPlaceholders(); // too -- so the modifier was pure redundancy) - return; - } - key = KEY_CONTEXT_MENU; - } else if (raw == 0x80) { - // Fn+Esc -- CardKB's lock/unlock gesture: a single press toggles _locked - // directly (unlike the physical Hold-Back+3xEnter combo's 3-press - // sequence), so it works to unlock a locked device too, where every - // other CardKB key is correctly discarded (see the Fn+ branch - // below). Esc, not the adjacent Fn+Backspace, on purpose: Fn and - // Backspace sit right next to each other on CardKB's layout, making that - // combo too easy to hit by accident; Esc is on the opposite side of the - // keyboard. One press is enough -- Fn+Esc is already a deliberate - // two-key combo, so it doesn't need the physical combo's extra 3x - // repetition to guard against accidental triggering. - if (_display && !_display->isOn()) _display->turnOn(); - toggleLock(); - return; - } else if (raw >= 0x80 && raw <= 0xAF) { // Fn+ -- open its accent popup - char base = CARDKB_FN_BASE[raw - 0x80]; - if (base == 0) return; // Fn+digit/symbol/arrow -- not used by this UI - char woke = checkDisplayOn(base); - // Every other key here goes through enqueueKey(), so it's naturally eaten - // while locked (see the dequeue-time "if (!_locked && curr)" gate in - // loop()). This path calls into the keyboard widget directly instead, so - // it needs its own _locked check -- otherwise a stray Fn+letter (e.g. the - // keyboard was left open before the device locked, or brushed against in - // a pocket) could pop the accent popup while the screen is supposed to - // ignore all input. - if (woke && !_locked) _kb.openAccentFor(base); - return; - } else { - // Plain Enter would otherwise commit whatever grid cell row/col happen to - // be frozen at (there's no grid navigation to have deliberately landed on - // one in Compact) -- submit instead, same as Fn+Enter. Backspace/ASCII - // passthrough is unaffected by Compact either way. - key = (compact_grid && raw == (uint8_t)KEY_ENTER) ? KEY_KB_ENTER : (char)raw; - } - enqueueKey(checkDisplayOn(key)); -#endif -} // Level-triggered, like pollCardKB() -- a magnet held near the sensor reads the // same way every tick, so this only acts on the two edges (closed/opened), not @@ -3128,141 +1052,7 @@ void UITask::pollHallSensor() { #endif } -#if defined(UI_PERF_L1) && defined(NRF52_PLATFORM) -// -D UI_PERF_L1: walks the main screens by itself (keys fed into the queue) and -// prints, per step over USB serial: frames drawn, frames the display skipped -// as unchanged, render/flush time (avg/max us), heap (used/free/largest -// block) and every FreeRTOS task's stack headroom. Measurement only. -#include "FreeRTOS.h" -#include "task.h" -#include -extern unsigned char __HeapBase[]; -extern unsigned char __HeapLimit[]; -extern uint32_t g_disp_flushes; -namespace perfl1 { - // Each step opens a screen directly (so the walk doesn't depend on which - // Home pages are enabled), then types keys: R/L/U/D/E/X, H = hold Enter. - enum Open : uint8_t { NONE, HOME, SETTINGS, MESSAGES, TOOLS, NEARBY, TRAIL, MAP, COMPASS, STATUS, - LIVE, LOCATOR, ADVERT, REPEATER, BOT, CLOCKTOOLS, CHANNEL0 }; - struct Step { const char* name; Open open; const char* keys; uint16_t dwell_ms; }; - static const Step STEPS[] = { - {"boot-idle", NONE, "", 3000}, - {"home", HOME, "", 2000}, - {"home-R1", NONE, "R", 1500}, {"home-R2", NONE, "R", 1500}, {"home-R3", NONE, "R", 1500}, - {"home-R4", NONE, "R", 1500}, {"home-R5", NONE, "R", 1500}, {"home-R6", NONE, "R", 1500}, - {"home-R7", NONE, "R", 1500}, {"home-R8", NONE, "R", 1500}, - {"home-R9", NONE, "R", 1500}, {"home-R10", NONE, "R", 1500}, {"home-R11", NONE, "R", 1500}, - {"home-R12", NONE, "R", 1500}, {"home-R13", NONE, "R", 1500}, - {"settings", SETTINGS, "", 2000}, {"set-display", NONE, "E", 2000}, - {"set-scroll", NONE, "DDDDDDDDDDDD", 2000}, - {"messages", MESSAGES, "", 2000}, {"dm-list", NONE, "E", 2000}, - {"dm-scroll", NONE, "DDDDDDDDDDDDDDDDDDDD", 2000}, - {"dm-thread", NONE, "E", 3000}, {"dm-thread-up", NONE, "UUUU", 2000}, - {"channel", CHANNEL0, "", 3000}, {"channel-up", NONE, "UUUUUU", 2000}, - {"compose", NONE, "EE", 2000}, {"kbd-type", NONE, "RRDRRDRRE", 2000}, - {"kbd-close", NONE, "XXX", 1500}, - {"nearby", NEARBY, "", 3000}, {"nearby-scroll", NONE, "DDDDDDDDDDDDDDDDDDDD", 2000}, - {"tools", TOOLS, "", 1500}, {"trail", TRAIL, "", 2000}, {"map", MAP, "", 3000}, - {"compass", COMPASS, "", 3000}, - {"st-radio", STATUS, "", 3000}, {"st-radio-dn", NONE, "DD", 2000}, {"st-gps", NONE, "UUR", 3000}, - {"st-gps-dn", NONE, "DDD", 2000}, {"st-sky", NONE, "UUUR", 4000}, {"st-signal", NONE, "E", 3000}, - {"st-power", NONE, "R", 2000}, {"st-mesh", NONE, "R", 2000}, {"st-mesh-dn", NONE, "DDD", 2000}, - {"st-system", NONE, "UUUR", 2000}, {"st-system-dn", NONE, "DDDDDD", 2000}, - {"liveshare", LIVE, "", 1500}, {"locator", LOCATOR, "", 1500}, {"autoadvert", ADVERT, "", 1500}, - {"repeater", REPEATER, "", 1500}, {"bot", BOT, "", 1500}, {"clocktools", CLOCKTOOLS, "", 1500}, - {"home-end", HOME, "", 3000}, - {"done", NONE, "", 500}, - }; - static int step = -1; - static uint32_t step_at = 0, frames = 0, r_sum = 0, r_max = 0, f_sum = 0, f_max = 0, fl0 = 0; - static uint32_t heap_min_free = 0xFFFFFFFF; - - static uint32_t largestBlock() { // bisect the biggest malloc that succeeds - uint32_t lo = 0, hi = (uint32_t)(__HeapLimit - __HeapBase); - while (hi - lo > 64) { - uint32_t mid = (lo + hi) / 2; - void* q = malloc(mid); - if (q) { free(q); lo = mid; } else hi = mid; - } - return lo; - } - static void heap(uint32_t& used, uint32_t& freeb) { - uint32_t total = (uint32_t)(__HeapLimit - __HeapBase); - used = (uint32_t)mallinfo().uordblks; - freeb = used < total ? total - used : 0; - } - void frame(uint32_t render_us, uint32_t flush_us) { - frames++; r_sum += render_us; f_sum += flush_us; - if (render_us > r_max) r_max = render_us; - if (flush_us > f_max) f_max = flush_us; - } - static void report(const char* name) { - uint32_t used, freeb; heap(used, freeb); - if (freeb < heap_min_free) heap_min_free = freeb; - uint32_t fl = g_disp_flushes - fl0; - Serial.printf("PERF %-12s frames %3lu sent %3lu render avg %5lu max %6lu flush avg %5lu max %6lu us | heap used %lu free %lu largest %lu min %lu\n", - name, frames, fl, frames ? r_sum / frames : 0, r_max, frames ? f_sum / frames : 0, f_max, - used, freeb, largestBlock(), heap_min_free); - } - static void tasks() { - TaskStatus_t st[16]; - UBaseType_t n = uxTaskGetSystemState(st, 16, NULL); - for (UBaseType_t i = 0; i < n; i++) - Serial.printf("TASK %-10s stack free %5lu B\n", st[i].pcTaskName, (unsigned long)st[i].usStackHighWaterMark * sizeof(StackType_t)); - } - static DisplayDriver* disp = nullptr; - static void shot(const char* name) { // the 1 KB page buffer as hex, for a PNG on the host - if (!disp || !disp->getBuffer()) return; - const uint8_t* b = disp->getBuffer(); - Serial.printf("SHOT %s ", name); - for (uint16_t i = 0; i < disp->getBufferSize(); i++) Serial.printf("%02x", b[i]); - Serial.println(); - } - static char keyOf(char c) { - switch (c) { case 'R': return KEY_RIGHT; case 'L': return KEY_LEFT; case 'U': return KEY_UP; - case 'D': return KEY_DOWN; case 'E': return KEY_ENTER; case 'X': return KEY_CANCEL; case 'H': return KEY_CONTEXT_MENU; } - return 0; - } - static const char* pending = nullptr; - static uint32_t next_key_at = 0; - // Returns a key to enqueue (0 = none). - Open open_now = NONE; - char tick() { - uint32_t now = millis(); - if (step >= (int)(sizeof(STEPS) / sizeof(STEPS[0]))) return 0; - if (pending && *pending) { - if ((int32_t)(now - next_key_at) < 0) return 0; - next_key_at = now + 300; - char k = keyOf(*pending++); - if (!*pending) { step_at = now; frames = r_sum = r_max = f_sum = f_max = 0; fl0 = g_disp_flushes; } - return k; - } - if (step >= 0 && (int32_t)(now - step_at) < (int32_t)STEPS[step].dwell_ms) return 0; - if (step >= 0) { report(STEPS[step].name); shot(STEPS[step].name); } - step++; - if (step >= (int)(sizeof(STEPS) / sizeof(STEPS[0]))) { tasks(); Serial.println("PERF end"); return 0; } - if (step == 0) tasks(); - pending = STEPS[step].keys; - open_now = STEPS[step].open; - step_at = now; frames = r_sum = r_max = f_sum = f_max = 0; fl0 = g_disp_flushes; - next_key_at = now; - return 0; - } -} -// micros() ticks in ~1 ms steps here (RTC-based), so time with the CPU cycle counter. -static inline uint32_t perfUs() { - static bool on = false; - if (!on) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; on = true; } - return DWT->CYCCNT; // cycles; differences stay right across the 67 s wrap -} -#define PERF_T0() uint32_t _pf0 = perfUs() -#define PERF_T1() uint32_t _pf1 = perfUs() -#define PERF_T2() perfl1::frame((_pf1 - _pf0) / (SystemCoreClock / 1000000), (perfUs() - _pf1) / (SystemCoreClock / 1000000)) -#else -#define PERF_T0() -#define PERF_T1() -#define PERF_T2() -#endif +#include "PerfL1.h" void UITask::loop() { #if defined(UI_PERF_L1) && defined(NRF52_PLATFORM)