Files
Meck/examples/companion_radio/ui-new/UITask.cpp
T
pelgraine 2ca97fa8ce v1.15: Settings > Experimental Features (delete all contacts, Alt+B-only backlight); TEMP Max charger readout
Version
- MyMesh.h: FIRMWARE_VERSION default "Meck v1.14" -> "Meck v1.15";
  FIRMWARE_BUILD_DATE updated.
- variants/lilygo_tdeck_pro/platformio.ini,
  variants/lilygo_tdeck_max/platformio.ini: FIRMWARE_VERSION strings
  v1.14 -> v1.15.

Settings > Experimental Features
- SettingsScreen.h: new "Experimental Features >>" folder row as the last
  top-level Settings row, opening "Settings > Experimental".
- Delete all contacts (all T-Deck builds), ported from Meck-P4 v0.7.4.
  Flow: "Delete all contacts?" box (Enter:Yes Q:No) -> second "Are you
  sure? This cannot be undone." popup (its Enter is ignored for the
  first second) -> "Purging" -> "Contacts deleted" or "Purge failed" ->
  restart 3 s later. Removes every contact, with favourites and custom
  paths, and the DM history; channel messages are kept. Q cancels at
  either confirmation; keys are ignored once the purge starts.
- MyMesh.h: purgeAllContacts() closes any chunked contact save still in
  progress, empties the contact table and saves the empty set.
- ChannelScreen.h: purgeDMs() marks the DM entries in
  /meshcore/messages.bin empty in place and re-saves, keeping channel
  messages, and clears the DM unread count.
- main.cpp: meckPurgeAllContacts() runs both and checks the contacts file
  is empty afterwards. All touch is ignored while the purge box is open.
- Change Backlight to Alt+B (Max only): ON stops the heart touch key
  toggling the frontlight, and Alt+B then lights it at the Backlight
  Brightness setting. OFF leaves both as before.
- NodePrefs.h, DataStore.cpp: new pref backlight_alt_b_only at byte 1522
  (prefs file now 1523 bytes); older files load it as 0 (heart key on).
  Timezones comment "these final fields" -> "these fields".

TEMPORARY (Max only; remove after the charging investigation)
- TDeckProMaxBoard.h, TDeckProMaxBoard.cpp: chargerDebugPrint(), a
  read-only SY6970 status, fault and register dump plus a BQ27220 gauge
  line on serial.
- UITask.cpp: calls it every 10 s from UITask::loop().
2026-10-07 06:33:58 +11:00

3182 lines
113 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "UITask.h"
#include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h"
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "PathEditorScreen.h"
#include "DiscoveryScreen.h"
#include "LastHeardScreen.h"
#include "RxLogScreen.h"
#include "Tracescreen.h"
#include "GamesMenuScreen.h"
#include "SnakeScreen.h"
#include "MinesweeperScreen.h"
#if defined(LilyGo_TDeck_Pro)
#include "GBCEmulatorScreen.h"
#endif
#ifdef MECK_WEB_READER
#include "WebReaderScreen.h"
#endif
#if HAS_GPS
#include "MapScreen.h"
#endif
#include "target.h"
#if defined(LilyGo_TDeck_Pro_Max)
#include "DRV2605Haptic.h" // haptic motor for "Buzzer (vibrate)" channels
#endif
#if defined(MECK_AUDIO_VARIANT) || defined(LilyGo_TDeck_Pro)
#include "HomeIcons.h"
#endif
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
#include <WiFi.h>
#endif
#if defined(BLE_PIN_CODE) && defined(MECK_WIFI_COMPANION)
// Combined BLE + WiFi companion (meck_max_ble_wifi): defined in main.cpp
extern bool meckCompanionIsBLE();
extern bool meckCompanionUseBLE();
extern void meckCompanionUseNone();
#endif
#ifndef AUTO_OFF_MILLIS
#define AUTO_OFF_MILLIS 15000 // 15 seconds
#endif
// Right-aligned values on the dense home pages compensate for the e-ink X
// origin offset so they are not clipped at the right edge. Default 0 for
// boards that do not define EINK_X_OFFSET (no change to their layout).
#ifndef EINK_X_OFFSET
#define EINK_X_OFFSET 0
#endif
#define BOOT_SCREEN_MILLIS 3000 // 3 seconds
#ifdef PIN_STATUS_LED
#define LED_ON_MILLIS 20
#define LED_ON_MSG_MILLIS 200
#define LED_CYCLE_MILLIS 4000
#endif
#define LONG_PRESS_MILLIS 1200
#ifndef UI_RECENT_LIST_SIZE
#define UI_RECENT_LIST_SIZE 4
#endif
#define PRESS_LABEL "long press"
#include "icons.h"
#include "ChannelScreen.h"
#include "ChannelPickerScreen.h"
#include "ContactsScreen.h"
#include "TextReaderScreen.h"
#include "SettingsScreen.h"
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#include "VoiceMessageScreen.h"
#endif
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#include "ModemManager.h"
#endif
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
#include "NotifSounds.h"
#endif
// Per-channel notification suppression flag.
// Set by newMsg() based on channel_notif preference, checked by notify()
// to suppress buzzer/vibration. Safe because both are called sequentially
// from the same mesh callback on the same thread.
static bool s_lastMsgSuppressed = false;
#ifdef KB_BL_PIN
// Drive the keyboard-backlight pin for the message/ring flash. On the T-Deck
// Pro MAX the manual (both-shifts) keyboard-backlight toggle drives this pin
// via LEDC (analogWrite); once LEDC is bound to a pin a later digitalWrite is
// swallowed, so the flash must use analogWrite too or it silently does nothing
// after the manual toggle has been used. Other boards keep the plain GPIO write.
static inline void kbBacklightFlashWrite(bool on) {
#if defined(LilyGo_TDeck_Pro_Max)
analogWrite(KB_BL_PIN, on ? 255 : 0);
#else
digitalWrite(KB_BL_PIN, on ? HIGH : LOW);
#endif
}
#endif
class SplashScreen : public UIScreen {
UITask* _task;
unsigned long dismiss_after;
char _version_info[24];
public:
SplashScreen(UITask* task) : _task(task) {
// strip off dash and commit hash by changing dash to null terminator
// e.g: v1.2.3-abcdef -> v1.2.3
const char *ver = FIRMWARE_VERSION;
const char *dash = strchr(ver, '-');
int len = dash ? dash - ver : strlen(ver);
if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1;
memcpy(_version_info, ver, len);
_version_info[len] = 0;
dismiss_after = millis() + BOOT_SCREEN_MILLIS;
}
int render(DisplayDriver& display) override {
// meshcore logo
display.setColor(DisplayDriver::BLUE);
int logoWidth = 128;
display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
// version info
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(2);
display.drawTextCentered(display.width()/2, 22, _version_info);
display.setTextSize(1);
display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE);
return 1000;
}
void poll() override {
if (millis() >= dismiss_after) {
Serial.println(">>> SplashScreen calling gotoHomeScreen() <<<");
_task->gotoHomeScreen();
}
}
};
class HomeScreen : public UIScreen {
enum HomePage {
FIRST,
RECENT,
RADIO,
#ifdef BLE_PIN_CODE
BLUETOOTH,
#endif
#ifdef MECK_WIFI_COMPANION
WIFI_STATUS,
#endif
ADVERT,
#if ENV_INCLUDE_GPS == 1 && !defined(MECK_40MHZ_TEST)
GPS,
#endif
#if UI_SENSORS_PAGE == 1
SENSORS,
#endif
#if HAS_BQ27220
BATTERY,
#endif
SHUTDOWN,
TIMEZONES, // world clock; last page, so A or a right swipe from the tiles reaches it
Count // keep as last
};
UITask* _task;
mesh::RTCClock* _rtc;
SensorManager* _sensors;
NodePrefs* _node_prefs;
uint8_t _page;
bool _shutdown_init;
unsigned long _shutdown_at; // earliest time to proceed with shutdown (after e-ink refresh)
bool _poweroff_selected; // true = "power off" highlighted, false = "hibernate"
bool _poweroff_confirm; // true = showing confirmation prompt for power off
bool _poweroff_msg_shown; // true = "powering off..." already displayed once
bool _editing_utc;
int8_t _saved_utc_offset; // for cancel/undo
// Timezones page: selected row (0 = Home, 1 = Zone 1, 2 = Zone 2) and
// the zone offset editor, which holds the edited value until saved.
uint8_t _tz_sel;
bool _tz_editing;
int8_t _tz_edit_val;
AdvertPath recent[UI_RECENT_LIST_SIZE];
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
// Use BQ27220 fuel gauge State of Charge when available — it tracks
// the real LiPo discharge curve, impedance, and temperature. The old
// linear voltage mapping (3.0–4.2V) over-reports while charging
// (voltage inflated by charger current) and is non-linear across the
// flat middle of the discharge curve.
uint8_t batteryPercentage = 0;
#if HAS_BQ27220
batteryPercentage = _task->getBatteryPercent();
#else
// Fallback for boards without a fuel gauge
if (batteryMilliVolts > 0) {
const int minMilliVolts = 3000;
const int maxMilliVolts = 4200;
int pct = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
batteryPercentage = (uint8_t)pct;
}
#endif
display.setColor(DisplayDriver::GREEN);
display.setTextSize(_node_prefs->smallTextSize());
// T-Deck Pro: icon + percentage text (icon hidden in large font)
int iconWidth = 16;
int iconHeight = 6;
int iconY = 0;
int textY = iconY - 3;
// measure percentage text width to position icon + text together at right edge
char pctStr[5];
sprintf(pctStr, "%d%%", batteryPercentage);
uint16_t textWidth = display.getTextWidth(pctStr);
if (_node_prefs->large_font || display.getFontStyle() > 0) {
// Large font or custom proportional font: text only — icon doesn't align
int textX = display.width() - textWidth - 2;
if (outIconX) *outIconX = textX;
display.setCursor(textX, textY);
display.print(pctStr);
} else {
// Classic tiny font (monospaced): icon + text
// layout: [icon][cap 2px][gap 2px][text][margin 2px]
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
int iconX = display.width() - totalWidth;
if (outIconX) *outIconX = iconX;
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
// battery "cap"
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2);
// fill the battery based on the percentage
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
// draw percentage text after the battery cap
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
display.setCursor(textX, textY);
display.print(pctStr);
}
display.setTextSize(1); // restore default text size
}
#ifdef MECK_AUDIO_VARIANT
// ---- Audio background playback indicator ----
// Shows a small play symbol to the left of the battery icon when an
// audiobook is actively playing in the background.
// Uses the font renderer (not manual pixel drawing) since it handles
// the e-ink coordinate scaling correctly.
void renderAudioIndicator(DisplayDriver& display, int batteryLeftX) {
if (!_task->isAudioPlayingInBackground()) return;
display.setColor(DisplayDriver::GREEN);
display.setTextSize(_node_prefs->smallTextSize()); // tiny font (same as clock & battery %)
int x = batteryLeftX - display.getTextWidth(">>") - 2;
display.setCursor(x, -3); // align vertically with battery text
display.print(">>");
display.setTextSize(1); // restore
}
// ---- Alarm enabled indicator ----
// Shows a small bell icon to the left of the audio indicator
// (or battery icon if no audio playing) when any alarm is enabled.
void renderAlarmIndicator(DisplayDriver& display, int batteryLeftX) {
AlarmScreen* alarmScr = (AlarmScreen*)_task->getAlarmScreen();
if (!alarmScr || alarmScr->enabledCount() == 0) return;
// Calculate X: shift left past audio indicator if it's showing
int rightEdge = batteryLeftX;
if (_task->isAudioPlayingInBackground()) {
display.setTextSize(_node_prefs->smallTextSize());
rightEdge = rightEdge - display.getTextWidth(">>") - 2;
}
display.setColor(DisplayDriver::GREEN);
int x = rightEdge - BELL_ICON_W - 2;
display.drawXbm(x, 1, icon_bell_small, BELL_ICON_W, BELL_ICON_H);
}
#endif
// ==========================================================================
// Timezones home page (world clock) -- ported from the watch and Meck-P4
//
// Row 0 = Home (the device utc_offset_hours), rows 1/2 = the two saved
// zones (prefs clock_slot_a/b). Each row shows the zone label and offset,
// the local HH:MM, a +/-1D day marker when the zone sits on a different
// calendar day to Home, and city codes for the offset (table copied from
// Meck-P4, which copied it from the watch). W/S or a long-press picks a
// row and Enter opens its offset editor; in the editor W/S or taps adjust
// the offset, Enter or a long-press saves, Q cancels. Editing Home
// changes the device UTC offset, as on the watch and P4.
// ==========================================================================
// Row 0 label y and row pitch, in virtual coordinates. Also used by
// selectTimezoneRowAt() for the touch long-press hit test.
enum { TZ_ROW_TOP = 16, TZ_ROW_H = 34 };
static const char* tzRowLabel(int r) {
return (r == 1) ? "Zone 1" : (r == 2) ? "Zone 2" : "Home";
}
static const char* tzCities(int8_t off) {
static const char* const CITIES[27] = {
"BIT/HWL", /* -12 */ "PPG/NIU/MDY", /* -11 */ "HNL/PPT/RAR", /* -10 */
"ANC/JNU", /* -9 */ "LAX/VAN/SEA", /* -8 */ "PHX/HMO/MZT", /* -7 */
"MEX/REG/GUA", /* -6 */ "BOG/LIM/PTY", /* -5 */ "CCS/LPB/SDQ", /* -4 */
"BUE/SAO/MVD", /* -3 */ "FEN/SGS", /* -2 */ "RAI/PDL", /* -1 */
"REY/ACC/DKR", /* 0 */ "LOS/ALG/TUN", /* +1 */ "JNB/KRT/HRE", /* +2 */
"NBO/MOW/ADD", /* +3 */ "DXB/BAK/TBS", /* +4 */ "KHI/TAS/SVX", /* +5 */
"DAC/ALA/OMS", /* +6 */ "BKK/JKT/HAN", /* +7 */ "PER/BEI/HKG", /* +8 */
"TYO/SEL/YKS", /* +9 */ "BNE/POM/VLA", /* +10 */ "NOU/HIR/VLI", /* +11 */
"SUV/TRW/MAJ", /* +12 */ "TBU/APW", /* +13 */ "CXI" /* +14 */
};
int i = (int)off + 12;
if (i < 0 || i > 26) return "";
return CITIES[i];
}
int8_t tzRowOffset(int r) const {
if (r == 1) return _node_prefs->clock_slot_a;
if (r == 2) return _node_prefs->clock_slot_b;
return _node_prefs->utc_offset_hours;
}
// Same clock-validity test as the header clock: before the clock is set
// the page shows "Clock not set" and its keys do nothing.
bool tzClockValid() const { return _rtc->getCurrentTime() > 1700000000; }
void renderTimezonesPage(DisplayDriver& display) {
char buf[24];
if (_tz_editing) {
// Offset editor, laid out like the watch's edit page: row name, the
// offset being edited, its city codes, then the key and touch hints.
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, 18, tzRowLabel(_tz_sel));
display.setTextSize(5);
display.setColor(DisplayDriver::LIGHT);
snprintf(buf, sizeof(buf), "UTC%+d", (int)_tz_edit_val);
display.drawTextCentered(display.width() / 2, 48, buf);
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, 64, tzCities(_tz_edit_val));
display.setTextSize(_node_prefs->smallTextSize());
display.drawTextCentered(display.width() / 2, 84, "W/S:adj Enter:ok Q:cancel");
display.drawTextCentered(display.width() / 2, 96, "Tap:-/+ Hold:save");
display.setTextSize(1);
return;
}
if (!tzClockValid()) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, 50, "Clock not set");
return;
}
const uint32_t now = _rtc->getCurrentTime();
const int32_t home_local = (int32_t)now + ((int32_t)tzRowOffset(0) * 3600);
for (int r = 0; r < 3; r++) {
const int ry = TZ_ROW_TOP + r * TZ_ROW_H;
const int8_t off = tzRowOffset(r);
const int32_t local = (int32_t)now + ((int32_t)off * 3600);
int hrs = (local / 3600) % 24;
if (hrs < 0) hrs += 24;
int mins = (local / 60) % 60;
if (mins < 0) mins += 60;
// Label line: selection marker, zone name and offset
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
if (r == _tz_sel) display.drawTextLeftAlign(0, ry, ">");
snprintf(buf, sizeof(buf), "%s UTC%+d", tzRowLabel(r), (int)off);
display.drawTextLeftAlign(7, ry, buf);
// Local time, large
display.setTextSize(5);
display.setColor(DisplayDriver::LIGHT);
snprintf(buf, sizeof(buf), "%02d:%02d", hrs, mins);
display.drawTextCentered(display.width() / 2, ry + 17, buf);
// Day marker (left) and city codes (right) under the time
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
const int dayDiff = (int)((local / 86400) - (home_local / 86400));
if (dayDiff != 0) {
snprintf(buf, sizeof(buf), "%+dD", dayDiff);
display.drawTextLeftAlign(7, ry + 27, buf);
}
display.drawTextRightAlign(display.width() - 1 - EINK_X_OFFSET, ry + 27, tzCities(off));
}
}
// Keys on the Timezones page. Returns true when the key is used here;
// anything else falls through to the page cycling in handleInput().
// Keyboard W/S arrive as 'w'/'s' because main.cpp passes them through on
// this page (as it passes S through on the Hibernate page). Touch taps
// arrive as KEY_PREV (left half) / KEY_NEXT (right half) and a long-press
// as KEY_ENTER, after selectTimezoneRowAt() has picked the row.
bool handleTimezonesKey(char c) {
if (_tz_editing) {
// Zone offset editor: intercept all keys. Q and Shift+Del never get
// here: main.cpp sends them to UITask::gotoHomeScreen(), whose
// cancelEditing() closes the editor without saving.
if (c == 'w' || c == KEY_NEXT) {
if (_tz_edit_val < 14) _tz_edit_val++;
} else if (c == 's' || c == KEY_PREV) {
if (_tz_edit_val > -12) _tz_edit_val--;
} else if (c == KEY_ENTER) {
if (_tz_sel == 1) _node_prefs->clock_slot_a = _tz_edit_val;
else if (_tz_sel == 2) _node_prefs->clock_slot_b = _tz_edit_val;
else _node_prefs->utc_offset_hours = _tz_edit_val;
the_mesh.savePrefs();
Serial.printf("Timezones: %s = UTC%+d\n", tzRowLabel(_tz_sel), (int)_tz_edit_val);
_tz_editing = false;
}
return true;
}
if (!tzClockValid()) return false;
if (c == 'w') { _tz_sel = (_tz_sel + 2) % 3; return true; }
if (c == 's') { _tz_sel = (_tz_sel + 1) % 3; return true; }
if (c == KEY_ENTER) {
_tz_edit_val = tzRowOffset(_tz_sel);
_tz_editing = true;
return true;
}
return false;
}
CayenneLPP sensors_lpp;
int sensors_nb = 0;
bool sensors_scroll = false;
int sensors_scroll_offset = 0;
int next_sensors_refresh = 0;
void refresh_sensors() {
if (millis() > next_sensors_refresh) {
sensors_lpp.reset();
sensors_nb = 0;
sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
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 ++;
}
sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE;
#if AUTO_OFF_MILLIS > 0
next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec
#else
next_sensors_refresh = millis() + 60000; // 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),
_shutdown_init(false), _shutdown_at(0), _poweroff_selected(false), _poweroff_confirm(false),
_poweroff_msg_shown(false), _editing_utc(false), _saved_utc_offset(0),
_tz_sel(0), _tz_editing(false), _tz_edit_val(0), sensors_lpp(200) { }
bool isEditingUTC() const { return _editing_utc; }
bool isFirstPage() const { return _page == HomePage::FIRST; }
bool isOnRecentPage() const { return _page == HomePage::RECENT; }
bool isOnShutdownPage() const { return _page == HomePage::SHUTDOWN; }
bool isOnTimezonesPage() const { return _page == HomePage::TIMEZONES; }
bool isEditingTimezone() const { return _tz_editing; }
// Touch long-press on the Timezones page (main.cpp mapTouchLongPress):
// select the zone row under the finger before the Enter that opens its
// editor. vy comes from touchToVirtual(), which lands 5 units below the
// render y of the same point (setCursor adds 5), so row r's label, time
// and city codes sit in the band from TZ_ROW_TOP + r * TZ_ROW_H to the
// next row's.
void selectTimezoneRowAt(int vy) {
if (_tz_editing) return; // in the editor a long-press saves instead
int r = (vy - TZ_ROW_TOP) / TZ_ROW_H;
if (vy < TZ_ROW_TOP) r = 0;
if (r > 2) r = 2;
_tz_sel = (uint8_t)r;
}
void cancelEditing() {
if (_editing_utc) {
_node_prefs->utc_offset_hours = _saved_utc_offset;
_editing_utc = false;
}
_tz_editing = false; // Timezones zone editor: discard the unsaved value
}
void poll() override {
if (_shutdown_init && millis() >= _shutdown_at && !_task->isButtonPressed()) {
if (_poweroff_selected) _task->setFullPowerOff(true);
_task->shutdown();
}
}
int render(DisplayDriver& display) override {
char tmp[80];
#if defined(LilyGo_TDeck_Pro)
_task->setHomeShowingTiles(false); // Reset — only set true on FIRST page
#endif
// Power off: full-screen message, no header
// First render: "powering off..." + wake instruction
// Second render onward: wake instruction only (persists on e-ink)
if (_shutdown_init && _poweroff_selected) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
if (!_poweroff_msg_shown) {
_poweroff_msg_shown = true;
display.drawTextCentered(display.width() / 2, 30, "powering off...");
display.drawTextCentered(display.width() / 2, 46, "plug in USB-C to turn on");
return 1500;
} else {
display.drawTextCentered(display.width() / 2, 38, "plug in USB-C to turn on");
return 5000;
}
}
// node name (tinyfont to avoid overlapping clock)
display.setTextSize(_node_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
char filtered_name[sizeof(_node_prefs->node_name)];
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
#define HOME_HDR_Y -3
display.setCursor(0, HOME_HDR_Y);
display.print(filtered_name);
// battery voltage + status icons
#ifdef MECK_AUDIO_VARIANT
int battLeftX = display.width(); // default if battery doesn't render
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
// audio background playback indicator (>> icon next to battery)
renderAudioIndicator(display, battLeftX);
// alarm enabled indicator (AL icon, left of audio or battery)
renderAlarmIndicator(display, battLeftX);
#else
renderBatteryIndicator(display, _task->getBattMilliVolts());
#endif
// centered clock — only show when time is valid
{
uint32_t now = _rtc->getCurrentTime();
if (now > 1700000000) { // valid timestamp (after ~Nov 2023)
// Apply UTC offset from prefs
int32_t local = (int32_t)now + ((int32_t)_node_prefs->utc_offset_hours * 3600);
int hrs = (local / 3600) % 24;
if (hrs < 0) hrs += 24;
int mins = (local / 60) % 60;
if (mins < 0) mins += 60;
char timeBuf[6];
sprintf(timeBuf, "%02d:%02d", hrs, mins);
display.setTextSize(_node_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
uint16_t tw = display.getTextWidth(timeBuf);
int clockX = (display.width() - tw) / 2;
// Ensure clock doesn't overlap the node name
int nameRight = display.getTextWidth(filtered_name) + 4;
if (clockX < nameRight) clockX = nameRight;
display.setCursor(clockX, HOME_HDR_Y);
display.print(timeBuf);
display.setTextSize(1); // restore
}
}
// curr page indicator
#if defined(LilyGo_TDeck_Pro)
int y = 8; // Tighter under header; frees room for the MSG strip above the tile grid
#else
int y = 14;
#endif
int x = display.width() / 2 - 5 * (HomePage::Count-1);
for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) {
if (i == _page) {
display.fillRect(x-1, y-1, 3, 3);
} else {
display.fillRect(x, y, 1, 1);
}
}
if (_page == HomePage::FIRST) {
#if defined(LilyGo_TDeck_Pro)
_task->setHomeShowingTiles(true);
#endif
#if defined(LilyGo_TDeck_Pro)
// ----- T-Deck Pro: Touch tile grid home screen (T-Watch / P4 style) -----
// Rendered in RAW PHYSICAL pixels (240x320) via GxEPDDisplay raw
// helpers, bypassing the 128x128 virtual scaling so icons stay 1:1
// and the dithered grey borders read as continuous bands.
// Border shades approximate greys with 1-bit dither (0 = solid,
// 1 = dark grey, 2 = light grey), cycling so no row pairs two
// identical shades and no column stacks the same shade.
// Layout must stay in sync with the touch hit-test in main.cpp
// mapTouchTap().
{
GxEPDDisplay* eink = static_cast<GxEPDDisplay*>(&display);
const uint16_t fg = eink->rawFgColor();
struct MaxTile { const uint8_t* icon; const char* label; uint8_t shade; };
static const MaxTile tiles[12] = {
{ icon_envelope, "Messages", 0 }, { icon_people, "Contacts", 1 },
{ icon_gear, "Settings", 2 }, { icon_search, "Discover", 0 },
{ icon_trace, "Trace", 1 }, { icon_map, "Maps", 2 },
{ icon_notepad, "Notes", 0 }, { icon_book, "Reader", 1 },
{ icon_headphones, "Audiobooks", 2 }, { icon_bell, "Alarm", 0 },
{ icon_globe, "Browser", 1 }, { icon_gamepad, "Games", 2 },
};
// Physical layout matching the approved mockup: 2 cols x 6 rows
// plus a full-width Phone tile, icon stacked above the label.
// Labels honour the user font style (Classic 5x7 / Noto 7pt /
// Montserrat 7pt) via drawTextRawStyled.
const int tileW = 111, tileH = 32, gapX = 5, gapY = 3;
const int gridX = 6;
const int gridY = 68; // below the status strip at y=46 (page dots render at physical ~30-38 with the Max dot row at virtual y=8)
const int radius = 6;
const int borderT = 3; // 3px band so the dither reads as grey
for (int i = 0; i < 12; i++) {
int row = i / 2, col = i % 2;
int tx = gridX + col * (tileW + gapX);
int ty = gridY + row * (tileH + gapY);
eink->drawRoundRectShadedRaw(tx, ty, tileW, tileH, radius, borderT, tiles[i].shade, fg);
eink->drawXbmRaw(tx + (tileW - HOME_ICON_W) / 2, ty + 5, tiles[i].icon, HOME_ICON_W, HOME_ICON_H, fg);
int lw = eink->measureTextRawStyled(tiles[i].label);
eink->drawTextRawStyled(tx + (tileW - lw) / 2, ty + 17, tiles[i].label, fg);
#ifdef MECK_40MHZ_TEST
// 40 MHz test build: Maps(5), Audiobooks(8), Alarm(9) and Browser(10)
// are gated -- knock out every other pixel so the tile reads greyed.
// Taps on these tiles are inert (see mapTouchTap in main.cpp).
if (i == 5 || i == 8 || i == 9 || i == 10) {
const uint16_t bg = (fg == GxEPD_BLACK) ? GxEPD_WHITE : GxEPD_BLACK;
for (int py = ty; py < ty + tileH; py++) {
for (int px = tx; px < tx + tileW; px++) {
if (((px + py) & 1) == 0) eink->drawPixelRaw(px, py, bg);
}
}
}
#endif
}
// Full-width Phone tile (row 6) -- omitted on Pro audio builds
// (MECK_AUDIO_VARIANT without HAS_4G_MODEM): no modem, and the
// tile's tap was already inert on those builds (see mapTouchTap).
#if !defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
{
int ty = gridY + 6 * (tileH + gapY);
int tw = tileW * 2 + gapX;
eink->drawRoundRectShadedRaw(gridX, ty, tw, tileH, radius, borderT, 0, fg);
eink->drawXbmRaw(gridX + (tw - HOME_ICON_W) / 2, ty + 5, icon_phone, HOME_ICON_W, HOME_ICON_H, fg);
int lw = eink->measureTextRawStyled("Phone");
eink->drawTextRawStyled(gridX + (tw - lw) / 2, ty + 17, "Phone", fg);
#ifdef MECK_40MHZ_TEST
// Phone is gated on the 40 MHz test build -- grey it out too
{
const uint16_t bg = (fg == GxEPD_BLACK) ? GxEPD_WHITE : GxEPD_BLACK;
for (int py = ty; py < ty + tileH; py++) {
for (int px = gridX; px < gridX + tw; px++) {
if (((px + py) & 1) == 0) eink->drawPixelRaw(px, py, bg);
}
}
}
#endif
}
#endif // Phone tile (Pro audio builds omit)
// Top status strip (physical, between page dots and grid): unread
// count plus connection state / WiFi IP / BLE pin
{
sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount());
char rightBuf[40];
rightBuf[0] = 0;
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
if (_task->hasConnection()) {
strcpy(rightBuf, "< Connected >");
}
#endif
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
if (rightBuf[0] == 0) {
IPAddress ip = WiFi.localIP();
if (ip != IPAddress(0,0,0,0)) {
snprintf(rightBuf, sizeof(rightBuf), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
}
}
#endif
#if defined(BLE_PIN_CODE) && defined(MECK_WIFI_COMPANION)
// Combined build: show the PIN only while Bluetooth is the connection
if (rightBuf[0] == 0 && meckCompanionIsBLE() && _task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
sprintf(rightBuf, "Pin:%d", the_mesh.getBLEPin());
}
#elif defined(BLE_PIN_CODE)
if (rightBuf[0] == 0 && _task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
sprintf(rightBuf, "Pin:%d", the_mesh.getBLEPin());
}
#endif
int stripY = 46;
int mw = eink->measureTextRawStyled(tmp);
if (rightBuf[0]) {
int rw = eink->measureTextRawStyled(rightBuf);
int startX = (eink->rawWidth() - (mw + 12 + rw)) / 2;
if (startX < 0) startX = 0;
eink->drawTextRawStyled(startX, stripY, tmp, fg);
eink->drawTextRawStyled(startX + mw + 12, stripY, rightBuf, fg);
} else {
eink->drawTextRawStyled((eink->rawWidth() - mw) / 2, stripY, tmp, fg);
}
}
display.setTextSize(1); // restore driver font state after raw text
}
#else // not LilyGo_TDeck_Pro
int y = 20;
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount());
display.drawTextCentered(display.width() / 2, y, tmp);
#if defined(LilyGo_TDeck_Pro_Max)
y += 10; // MAX: pull < Connected > up under MSG to make room for [T] Phone
#else
y += 14; // Reduced from 18
#endif
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
IPAddress ip = WiFi.localIP();
if (ip != IPAddress(0,0,0,0)) {
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
display.setTextSize(_node_prefs->smallTextSize()); // Tiny font for IP
display.drawTextCentered(display.width() / 2, y, tmp);
y += _node_prefs->smallLineH() - 1;
}
#endif
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(_node_prefs->smallTextSize()); // Tiny font for Connected
display.drawTextCentered(display.width() / 2, y, "< Connected >");
y += _node_prefs->smallLineH() - 1;
#ifdef BLE_PIN_CODE
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, y, tmp);
#if defined(LilyGo_TDeck_Pro_Max)
y += 6; // MAX: tighter pin-to-menu gap so [T] Phone is not clipped
#else
y += 18;
#endif
#endif
}
#endif
// Keyboard shortcut menu
// ----- T-Deck Pro: Keyboard shortcut text menu -----
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(_node_prefs->smallTextSize());
int menuLH = _node_prefs->smallLineH();
if (_node_prefs->large_font || display.getFontStyle() > 0) {
// Proportional font: two-column layout with fixed X positions
y += 2;
int col1, col2;
if (_node_prefs->large_font) {
col1 = 2;
int leftW = display.getTextWidth("[M] Messages");
col2 = col1 + leftW + 3;
} else {
col1 = display.width() / 10;
col2 = display.width() * 11 / 20;
}
display.setCursor(col1, y); display.print("[M] Messages");
display.setCursor(col2, y); display.print("[C] Contacts");
y += menuLH;
display.setCursor(col1, y); display.print("[N] Notes");
display.setCursor(col2, y); display.print("[S] Settings");
y += menuLH;
#if HAS_GPS
display.setCursor(col1, y); display.print("[E] Reader");
display.setCursor(col2, y); display.print("[G] Maps");
#else
display.setCursor(col1, y); display.print("[E] Reader");
#endif
y += menuLH;
#if defined(HAS_4G_MODEM) && defined(MECK_AUDIO_VARIANT)
display.setCursor(col1, y); display.print("[P] Audio");
display.setCursor(col2, y); display.print("[K] Alarm");
y += menuLH;
#ifdef MECK_WEB_READER
display.setCursor(col1, y); display.print("[B] Browser");
display.setCursor(col2, y); display.print("[F] Discover");
#else
display.setCursor(col1, y); display.print("[F] Discover");
#endif
y += menuLH;
display.setCursor(col1, y); display.print("[T] Phone");
#elif defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
display.setCursor(col1, y); display.print("[T] Phone");
display.setCursor(col2, y); display.print("[B] Browser");
#elif defined(HAS_4G_MODEM)
display.setCursor(col1, y); display.print("[T] Phone");
display.setCursor(col2, y); display.print("[F] Discover");
#elif defined(MECK_AUDIO_VARIANT)
display.setCursor(col1, y); display.print("[P] Audio");
display.setCursor(col2, y); display.print("[K] Alarm");
y += menuLH;
#ifdef MECK_WEB_READER
display.setCursor(col1, y); display.print("[B] Browser");
display.setCursor(col2, y); display.print("[F] Discover");
#else
display.setCursor(col1, y); display.print("[F] Discover");
#endif
#elif defined(MECK_WEB_READER)
display.setCursor(col1, y); display.print("[B] Browser");
#else
display.setCursor(col1, y); display.print("[F] Discover");
#endif
y += menuLH;
display.setColor(DisplayDriver::YELLOW);
display.setCursor(col1, y); display.print("[R] Trace");
display.setCursor(col2, y); display.print("[J] Games");
display.setColor(DisplayDriver::LIGHT);
y += menuLH;
y += 2;
} else {
// Monospaced built-in font (Classic): centered space-padded strings
#if defined(LilyGo_TDeck_Pro_Max)
y += 2; // MAX: Press sits closer under < Connected >
#else
y += 6;
#endif
display.drawTextCentered(display.width() / 2, y, "Press:");
y += 12;
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
#if HAS_GPS
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
#else
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
#endif
y += 10;
#if defined(HAS_4G_MODEM) && defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [K] Alarm ");
y += 10;
#ifdef MECK_WEB_READER
display.drawTextCentered(display.width() / 2, y, "[B] Browser [F] Discover ");
#else
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
#endif
#elif defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
#elif defined(HAS_4G_MODEM)
display.drawTextCentered(display.width() / 2, y, "[T] Phone [F] Discover ");
#elif defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [K] Alarm ");
y += 10;
#ifdef MECK_WEB_READER
display.drawTextCentered(display.width() / 2, y, "[B] Browser [F] Discover ");
#else
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
#endif
#elif defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
#else
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
#endif
y += 10;
display.setColor(DisplayDriver::YELLOW);
display.drawTextCentered(display.width() / 2, y, "[R] Trace [J] Games ");
display.setColor(DisplayDriver::LIGHT);
y += 10;
#if defined(HAS_4G_MODEM) && defined(MECK_AUDIO_VARIANT)
// Phone on its own line below Trace/Games, centered like the "Press:" header
display.drawTextCentered(display.width() / 2, y, "[T] Phone");
y += 14;
#endif
}
// Nav hint (only if room)
if (y < display.height() - 14) {
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y,
(_node_prefs->large_font || display.getFontStyle() > 0) ? "A/D: cycle views" : "Press A/D to cycle home views");
}
display.setTextSize(1); // restore
#endif // not LilyGo_TDeck_Pro
} else if (_page == HomePage::RECENT) {
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
display.setColor(DisplayDriver::GREEN);
int y = 20;
for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) {
auto a = &recent[i];
if (a->name[0] == 0) continue; // empty slot
int secs = _rtc->getCurrentTime() - a->recv_timestamp;
if (secs < 60) {
sprintf(tmp, "%ds", secs);
} else if (secs < 60*60) {
sprintf(tmp, "%dm", secs / 60);
} else {
sprintf(tmp, "%dh", secs / (60*60));
}
int timestamp_width = display.getTextWidth(tmp);
int max_name_width = display.width() - timestamp_width - 1 - EINK_X_OFFSET;
char filtered_recent_name[sizeof(a->name)];
display.translateUTF8ToBlocks(filtered_recent_name, a->name, sizeof(filtered_recent_name));
display.drawTextEllipsized(0, y, max_name_width, filtered_recent_name);
display.setCursor(display.width() - timestamp_width - 1 - EINK_X_OFFSET, y);
display.print(tmp);
}
// Hint for full Last Heard screen
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(_node_prefs->smallTextSize());
display.drawTextCentered(display.width() / 2, display.height() - 24,
"H: Full Last Heard list");
} else if (_page == HomePage::RADIO) {
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(1);
// freq / sf
display.setCursor(0, 20);
sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf);
display.print(tmp);
display.setCursor(0, 31);
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
display.print(tmp);
// tx power, noise floor
display.setCursor(0, 42);
sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm);
display.print(tmp);
display.setCursor(0, 53);
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
display.print(tmp);
display.setCursor(0, 64);
sprintf(tmp, "RX packets: %u", (unsigned)the_mesh.getRxPacketCount());
display.print(tmp);
#if defined(BLE_PIN_CODE) && defined(MECK_WIFI_COMPANION)
} else if (_page == HomePage::BLUETOOTH) {
// Combined build: Bluetooth counts as on only while it is the
// connection, not while WiFi is
bool btOn = meckCompanionIsBLE() && _task->isSerialEnabled();
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18,
btOn ? bluetooth_on : bluetooth_off,
32, 32);
if (btOn && _task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 53, "< Connected >");
} else if (btOn && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 53, tmp);
}
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 68, "toggle: " PRESS_LABEL);
display.drawTextCentered(display.width() / 2, 78, "or press Enter key");
#elif defined(BLE_PIN_CODE)
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18,
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
32, 32);
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 53, "< Connected >");
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 53, tmp);
}
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 68, "toggle: " PRESS_LABEL);
display.drawTextCentered(display.width() / 2, 78, "or press Enter key");
#endif
#ifdef MECK_WIFI_COMPANION
} else if (_page == HomePage::WIFI_STATUS) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
int wy = 36;
display.setTextSize(_node_prefs->smallTextSize());
int wLH = _node_prefs->smallLineH() + 1;
if (WiFi.status() == WL_CONNECTED) {
display.setColor(DisplayDriver::GREEN);
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += wLH;
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += wLH;
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += wLH + 2;
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, wy, "< App Connected >");
} else {
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, wy, "Waiting for app...");
}
} else {
display.setColor(DisplayDriver::RED);
display.drawTextCentered(display.width() / 2, wy, "Not connected");
wy += wLH + 2;
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
}
display.setTextSize(1);
#endif
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
display.drawTextCentered(display.width() / 2, 57, "advert: " PRESS_LABEL);
display.drawTextCentered(display.width() / 2, 67, "or press Enter key");
#if ENV_INCLUDE_GPS == 1 && !defined(MECK_40MHZ_TEST)
} else if (_page == HomePage::GPS) {
extern GPSStreamCounter gpsStream;
LocationProvider* nmea = sensors.getLocationProvider();
char buf[50];
int y = 18;
// GPS state line
if (!_node_prefs->gps_enabled) {
strcpy(buf, "gps off");
} else {
strcpy(buf, "gps on");
}
display.drawTextLeftAlign(0, y, buf);
if (nmea == NULL) {
y = y + 12;
display.drawTextLeftAlign(0, y, "Can't access GPS");
} else {
strcpy(buf, nmea->isValid()?"fix":"no fix");
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y = y + 12;
display.drawTextLeftAlign(0, y, "sat");
sprintf(buf, "%d", nmea->satellitesCount());
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y = y + 12;
// NMEA sentence counter — confirms baud rate and data flow
display.drawTextLeftAlign(0, y, "sentences");
if (_node_prefs->gps_enabled) {
uint16_t sps = gpsStream.getSentencesPerSec();
uint32_t total = gpsStream.getSentenceCount();
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
} else {
strcpy(buf, "hw off");
}
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y = y + 12;
display.drawTextLeftAlign(0, y, "pos");
sprintf(buf, "%.4f %.4f",
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y = y + 12;
display.drawTextLeftAlign(0, y, "alt");
sprintf(buf, "%.2f", nmea->getAltitude()/1000.);
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y = y + 12;
}
// Show RTC time and UTC offset on GPS page
{
uint32_t now = _rtc->getCurrentTime();
if (now > 1700000000) {
int32_t local = (int32_t)now + ((int32_t)_node_prefs->utc_offset_hours * 3600);
int hrs = (local / 3600) % 24;
if (hrs < 0) hrs += 24;
int mins = (local / 60) % 60;
if (mins < 0) mins += 60;
display.drawTextLeftAlign(0, y, "time(U)");
sprintf(buf, "%02d:%02d UTC%+d", hrs, mins, _node_prefs->utc_offset_hours);
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
} else {
display.drawTextLeftAlign(0, y, "time(U)");
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, "no sync");
}
y = y + 12;
}
// UTC offset editor overlay
if (_editing_utc) {
// Draw background box
int bx = 4, by = 20, bw = display.width() - 8, bh = 40;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
// Show current offset value
display.setTextSize(2);
sprintf(buf, "UTC%+d", _node_prefs->utc_offset_hours);
display.drawTextCentered(display.width() / 2, by + 4, buf);
// Show controls hint
display.setTextSize(_node_prefs->smallTextSize());
display.drawTextCentered(display.width() / 2, by + bh - 10, "W/S:adj Enter:ok Q:cancel");
display.setTextSize(1);
}
#endif
#if UI_SENSORS_PAGE == 1
} else if (_page == HomePage::SENSORS) {
int y = 18;
refresh_sensors();
char buf[30];
char name[30];
LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize());
for (int i = 0; i < sensors_scroll_offset; i++) {
uint8_t channel, type;
r.readHeader(channel, type);
r.skipData(type);
}
for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) {
uint8_t channel, type;
if (!r.readHeader(channel, type)) { // reached end, reset
r.reset();
r.readHeader(channel, type);
}
display.setCursor(0, y);
float v;
switch (type) {
case LPP_GPS: // GPS
float lat, lon, alt;
r.readGPS(lat, lon, alt);
strcpy(name, "gps"); sprintf(buf, "%.4f %.4f", lat, lon);
break;
case LPP_VOLTAGE:
r.readVoltage(v);
strcpy(name, "voltage"); sprintf(buf, "%6.2f", v);
break;
case LPP_CURRENT:
r.readCurrent(v);
strcpy(name, "current"); sprintf(buf, "%.3f", v);
break;
case LPP_TEMPERATURE:
r.readTemperature(v);
strcpy(name, "temperature"); sprintf(buf, "%.2f", v);
break;
case LPP_RELATIVE_HUMIDITY:
r.readRelativeHumidity(v);
strcpy(name, "humidity"); sprintf(buf, "%.2f", v);
break;
case LPP_BAROMETRIC_PRESSURE:
r.readPressure(v);
strcpy(name, "pressure"); sprintf(buf, "%.2f", v);
break;
case LPP_ALTITUDE:
r.readAltitude(v);
strcpy(name, "altitude"); sprintf(buf, "%.0f", v);
break;
case LPP_POWER:
r.readPower(v);
strcpy(name, "power"); sprintf(buf, "%6.2f", v);
break;
default:
r.skipData(type);
strcpy(name, "unk"); sprintf(buf, "");
}
display.setCursor(0, y);
display.print(name);
display.setCursor(
display.width()-display.getTextWidth(buf)-1, y
);
display.print(buf);
y = y + 12;
}
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
else sensors_scroll_offset = 0;
#endif
#if HAS_BQ27220
} else if (_page == HomePage::BATTERY) {
char buf[30];
int y = 18;
// Title
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Battery Gauge");
y += 12;
display.setColor(DisplayDriver::LIGHT);
// Time to empty
uint16_t tte = board.getTimeToEmpty();
display.drawTextLeftAlign(0, y, "remaining");
if (tte == 0xFFFF || tte == 0) {
strcpy(buf, tte == 0 ? "depleted" : "charging");
} else if (tte >= 60) {
sprintf(buf, "%dh %dm", tte / 60, tte % 60);
} else {
sprintf(buf, "%d min", tte);
}
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y += 10;
// Average current
int16_t avgCur = board.getAvgCurrent();
display.drawTextLeftAlign(0, y, "avg current");
sprintf(buf, "%d mA", avgCur);
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y += 10;
// Average power
int16_t avgPow = board.getAvgPower();
display.drawTextLeftAlign(0, y, "avg power");
sprintf(buf, "%d mW", avgPow);
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y += 10;
// Voltage (already available)
uint16_t mv = board.getBattMilliVolts();
display.drawTextLeftAlign(0, y, "voltage");
sprintf(buf, "%d.%03d V", mv / 1000, mv % 1000);
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y += 10;
// Remaining capacity (clamped to design capacity — gauge FCC may be
// stale from factory defaults until a full charge cycle re-learns it)
uint16_t remCap = board.getRemainingCapacity();
uint16_t desCap = board.getDesignCapacity();
if (desCap > 0 && remCap > desCap) remCap = desCap;
display.drawTextLeftAlign(0, y, "remaining cap");
sprintf(buf, "%d mAh", remCap);
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
y += 10;
// Battery temperature
int16_t battTemp = board.getBattTemperature();
display.drawTextLeftAlign(0, y, "temperature");
sprintf(buf, "%d.%d C", battTemp / 10, abs(battTemp % 10));
display.drawTextRightAlign(display.width()-1-EINK_X_OFFSET, y, buf);
#endif
}
else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
if (_shutdown_init) {
display.drawTextCentered(display.width() / 2, 34, "hibernating...");
} else if (_poweroff_confirm) {
// Confirmation prompt for power off
display.drawXbm((display.width() - 32) / 2, 20, power_icon, 32, 32);
display.drawTextCentered(display.width() / 2, 56, "power off device?");
display.drawTextCentered(display.width() / 2, 66, "usb-c to wake");
display.drawTextCentered(display.width() / 2, 82, "Enter:yes q:no");
} else {
// Menu: hibernate / power off
display.drawXbm((display.width() - 32) / 2, 20, power_icon, 32, 32);
const int y1 = 56, y2 = 68;
char line1[48], line2[48];
#if defined(LilyGo_TDeck_Pro)
snprintf(line1, sizeof(line1), "%shibernate: long press/Enter", _poweroff_selected ? " " : ">");
snprintf(line2, sizeof(line2), "%spower off: long press/Enter", _poweroff_selected ? ">" : " ");
#else
snprintf(line1, sizeof(line1), "%shibernate: " PRESS_LABEL, _poweroff_selected ? " " : ">");
snprintf(line2, sizeof(line2), "%spower off: " PRESS_LABEL, _poweroff_selected ? ">" : " ");
#endif
display.drawTextCentered(display.width() / 2, y1, line1);
display.drawTextCentered(display.width() / 2, y2, line2);
}
}
else if (_page == HomePage::TIMEZONES) {
renderTimezonesPage(display);
}
return (_editing_utc || _tz_editing) ? 700 : 5000;
}
bool handleInput(char c) override {
// UTC offset editing mode - intercept all keys
if (_editing_utc) {
if (c == 'w' || c == KEY_PREV) {
// Increment offset
if (_node_prefs->utc_offset_hours < 14) {
_node_prefs->utc_offset_hours++;
}
return true;
}
if (c == 's' || c == KEY_NEXT) {
// Decrement offset
if (_node_prefs->utc_offset_hours > -12) {
_node_prefs->utc_offset_hours--;
}
return true;
}
if (c == KEY_ENTER) {
// Save and exit
Serial.printf("UTC offset saving: %d\n", _node_prefs->utc_offset_hours);
the_mesh.savePrefs();
_editing_utc = false;
_task->showAlert("UTC offset saved", 800);
Serial.println("UTC offset save complete");
return true;
}
if (c == 'q' || c == 'u') {
// Cancel - restore original value
_node_prefs->utc_offset_hours = _saved_utc_offset;
_editing_utc = false;
return true;
}
return true; // Consume all other keys while editing
}
// Timezones page: W/S row select, Enter to edit, and the zone editor's
// keys (see handleTimezonesKey). Keys it does not use fall through to
// the page cycling below.
if (_page == HomePage::TIMEZONES && handleTimezonesKey(c)) {
return true;
}
// SHUTDOWN page -- intercept up/down and Enter before page cycling
if (_page == HomePage::SHUTDOWN) {
if (_poweroff_confirm) {
// Confirmation mode for power off
if (c == KEY_ENTER) {
_shutdown_init = true;
_shutdown_at = millis() + 2500; // extra time for two-phase e-ink update
return true;
}
// Cancel: q, left, prev
if (c == 'q' || c == KEY_LEFT || c == KEY_PREV) {
_poweroff_confirm = false;
return true;
}
return true; // eat all other keys while confirming
}
// Up/down toggles between hibernate and power off
// Only 'w'/'s' (keyboard) — KEY_NEXT/KEY_PREV fall through to page cycling
// so touch swipes and taps can still navigate away from this page.
if (c == 'w' || c == 's') {
_poweroff_selected = !_poweroff_selected;
return true;
}
if (c == KEY_ENTER) {
if (_poweroff_selected) {
_poweroff_confirm = true;
} else {
_shutdown_init = true;
_shutdown_at = millis() + 900;
}
return true;
}
// Left/right fall through to page cycling below
}
if (c == KEY_LEFT || c == KEY_PREV || c == 'a') {
_page = (_page + HomePage::Count - 1) % HomePage::Count;
return true;
}
if (c == KEY_NEXT || c == KEY_RIGHT || c == 'd') {
_page = (_page + 1) % HomePage::Count;
if (_page == HomePage::RECENT) {
_task->showAlert("Recent adverts", 800);
}
return true;
}
#if defined(BLE_PIN_CODE) && defined(MECK_WIFI_COMPANION)
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
// Combined build: Bluetooth on turns WiFi off; Bluetooth off leaves
// neither on. Once the Web Reader has freed Bluetooth, it needs a reboot.
if (meckCompanionIsBLE()) {
meckCompanionUseNone();
} else if (!meckCompanionUseBLE()) {
_task->showAlert("Reboot for Bluetooth", 1500);
}
return true;
}
#elif defined(BLE_PIN_CODE)
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
_task->disableSerial();
} else {
_task->enableSerial();
}
return true;
}
#endif
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
_task->showAlert("Advert sent!", 1000);
} else {
_task->showAlert("Advert failed..", 1000);
}
return true;
}
#if ENV_INCLUDE_GPS == 1 && !defined(MECK_40MHZ_TEST)
if (c == KEY_ENTER && _page == HomePage::GPS) {
_task->toggleGPS();
return true;
}
if (c == 'u' && _page == HomePage::GPS) {
_editing_utc = true;
_saved_utc_offset = _node_prefs->utc_offset_hours;
return true;
}
#endif
#if UI_SENSORS_PAGE == 1
if (c == KEY_ENTER && _page == HomePage::SENSORS) {
_task->toggleGPS();
next_sensors_refresh=0;
return true;
}
#endif
return false;
}
};
// MsgPreviewScreen removed — all platforms now use toast alerts for new messages
// ==========================================================================
// Lock Screen -- T-Deck Pro
// Big clock, battery %, unread message count.
// T-Deck Pro: Double-press boot button to lock/unlock. Touch+keyboard disabled.
// ==========================================================================
#if defined(LilyGo_TDeck_Pro)
class LockScreen : public UIScreen {
UITask* _task;
mesh::RTCClock* _rtc;
NodePrefs* _node_prefs;
public:
LockScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* node_prefs)
: _task(task), _rtc(rtc), _node_prefs(node_prefs) {}
int render(DisplayDriver& display) override {
uint32_t now = _rtc->getCurrentTime();
char timeBuf[6] = "--:--";
if (now > 1700000000) {
int32_t local = (int32_t)now + ((int32_t)_node_prefs->utc_offset_hours * 3600);
int hrs = (local / 3600) % 24;
if (hrs < 0) hrs += 24;
int mins = (local / 60) % 60;
if (mins < 0) mins += 60;
sprintf(timeBuf, "%02d:%02d", hrs, mins);
}
// ---- Huge clock: HH:MM on one line ----
display.setTextSize(5); // T-Deck Pro: FreeSansBold12pt at GxEPD 2× scale
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 55, timeBuf);
// ---- Battery + unread on one line ----
display.setTextSize(1);
{
int pct = 0;
#if HAS_BQ27220
pct = _task->getBatteryPercent();
#else
uint16_t mv = _task->getBattMilliVolts();
if (mv > 0) {
pct = ((mv - 3000) * 100) / (4200 - 3000);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
}
#endif
int unread = _task->getUnreadMsgCount();
char infoBuf[32];
if (unread > 0) {
sprintf(infoBuf, "%d%% | %d unread", pct, unread);
} else {
sprintf(infoBuf, "%d%%", pct);
}
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, 108, infoBuf);
}
#ifdef HAS_4G_MODEM
// ---- Unread SMS / missed-call notification ----
// Sourced from the SMS screen's own unread and unseen state, so it
// persists across re-locks and reboots until the conversation or the
// call log is actually opened.
{
SMSScreen* smsScr = _task->getSMSScreen();
int sms = smsScr ? smsScr->getUnreadSmsCount() : 0;
int missed = smsScr ? smsScr->getUnseenMissedCount() : 0;
if (sms > 0 || missed > 0) {
char notifBuf[32];
if (sms > 0 && missed > 0) {
sprintf(notifBuf, "SMS: %d Missed: %d", sms, missed);
} else if (sms > 0) {
sprintf(notifBuf, "SMS: %d", sms);
} else {
sprintf(notifBuf, "Missed: %d", missed);
}
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, display.height() - 12, notifBuf);
}
}
#endif
// ---- Unlock hint ----
return 30000;
}
bool handleInput(char c) override {
return false;
}
};
#endif
void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
_display = display;
_sensors = sensors;
_auto_off = millis() + AUTO_OFF_MILLIS;
#if (defined(PIN_USER_BTN))
user_btn.begin();
#endif
#if defined(PIN_USER_BTN_ANA)
analog_btn.begin();
#endif
_node_prefs = node_prefs;
// Initialize message dedup ring buffer
memset(_dedup, 0, sizeof(_dedup));
_dedupIdx = 0;
// Allocate per-contact DM unread tracking (PSRAM if available)
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_dmUnread = (uint8_t*)ps_calloc(MAX_CONTACTS, sizeof(uint8_t));
#else
_dmUnread = new uint8_t[MAX_CONTACTS]();
#endif
#if ENV_INCLUDE_GPS == 1
// Apply GPS preferences from stored prefs
if (_sensors != NULL && _node_prefs != NULL) {
_sensors->setSettingValue("gps", _node_prefs->gps_enabled ? "1" : "0");
if (_node_prefs->gps_interval > 0) {
char interval_str[12]; // Max: 24 hours = 86400 seconds (5 digits + null)
sprintf(interval_str, "%u", _node_prefs->gps_interval);
_sensors->setSettingValue("gps_interval", interval_str);
}
}
#endif
if (_display != NULL) {
_display->turnOn();
}
#ifdef PIN_BUZZER
buzzer.begin();
buzzer.quiet(_node_prefs->buzzer_quiet);
#endif
#ifdef PIN_VIBRATION
vibration.begin();
#endif
// Keyboard backlight for message flash notifications
#ifdef KB_BL_PIN
pinMode(KB_BL_PIN, OUTPUT);
digitalWrite(KB_BL_PIN, LOW);
#endif
#ifdef HAS_4G_MODEM
// Sync ringtone enabled state to modem manager
modemManager.setRingtoneEnabled(node_prefs->ringtone_enabled);
#endif
ui_started_at = millis();
_alert_expiry = 0;
#if defined(LilyGo_TDeck_Pro)
_lastInputMillis = millis();
#endif
splash = new SplashScreen(this);
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
channel_screen = new ChannelScreen(this, &rtc_clock);
((ChannelScreen*)channel_screen)->setDMUnreadPtr(_dmUnread);
channel_picker_screen = new ChannelPickerScreen(this);
((ChannelPickerScreen*)channel_picker_screen)->setChannelScreen((ChannelScreen*)channel_screen);
contacts_screen = new ContactsScreen(this, &rtc_clock);
((ContactsScreen*)contacts_screen)->setDMUnreadPtr(_dmUnread);
text_reader = new TextReaderScreen(this, node_prefs);
notes_screen = new NotesScreen(this, node_prefs);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
path_editor = nullptr; // Lazy-initialized on first use from contacts screen
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
last_heard_screen = new LastHeardScreen(&rtc_clock);
rxlog_screen = new RxLogScreen(this, &rtc_clock);
trace_screen = new TraceScreen(this, &rtc_clock);
games_menu_screen = new GamesMenuScreen(this);
snake_screen = new SnakeScreen(this, &rtc_clock);
minesweeper_screen = new MinesweeperScreen(this);
#if defined(LilyGo_TDeck_Pro)
gbc_screen = new GBCEmulatorScreen(this);
#endif
#if defined(LilyGo_TDeck_Pro)
lock_screen = new LockScreen(this, &rtc_clock, node_prefs);
#endif
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#ifdef MECK_AUDIO_VARIANT
alarm_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
voice_screen = nullptr; // Created and assigned from main.cpp on first mic key press
#endif
#ifdef HAS_4G_MODEM
sms_screen = new SMSScreen(this, node_prefs);
#endif
#if HAS_GPS
map_screen = new MapScreen(this);
#else
map_screen = nullptr;
#endif
// Apply saved dark mode preference (T-Deck Pro)
#if defined(LilyGo_TDeck_Pro)
if (_node_prefs->dark_mode) {
::display.setDarkMode(true);
}
// Apply saved font style preference (Classic / Noto Sans / Montserrat)
if (_node_prefs->ui_font_style > 0) {
::display.setFontStyle(_node_prefs->ui_font_style);
}
#endif
setCurrScreen(splash);
}
void UITask::showAlert(const char* text, int duration_millis) {
strcpy(_alert, text);
_alert_expiry = millis() + duration_millis;
_next_refresh = millis() + 100; // trigger re-render to show updated text
}
void UITask::showBootHint(bool immediate) {
if (immediate) {
// Activate now — used when hint should overlay the current screen (e.g. onboarding)
_hintActive = true;
_hintExpiry = millis() + 8000; // 8 seconds auto-dismiss
_pendingBootHint = false;
_next_refresh = millis() + 100;
Serial.println("[UI] Boot hint activated (immediate)");
} else {
// Defer until after splash screen — actual activation happens in gotoHomeScreen()
_pendingBootHint = true;
Serial.println("[UI] Boot hint pending (will show after splash)");
}
}
void UITask::dismissBootHint() {
if (!_hintActive) return;
_hintActive = false;
_hintExpiry = 0;
// Persist so hint never shows again
if (_node_prefs) {
_node_prefs->hint_shown = 1;
the_mesh.savePrefs();
}
_next_refresh = millis() + 100;
Serial.println("[UI] Boot hint dismissed");
}
void UITask::notify(UIEventType t) {
// Per-channel notification gating: if the last message was from a
// muted channel (or mentions-only without an @mention), suppress
// buzzer and vibration. Ack events are never suppressed.
if (s_lastMsgSuppressed && t != UIEventType::ack) {
s_lastMsgSuppressed = false; // Consume the flag
return;
}
s_lastMsgSuppressed = false;
#if defined(PIN_BUZZER)
switch(t){
case UIEventType::contactMessage:
// gemini's pick
buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
break;
case UIEventType::channelMessage:
buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#");
break;
case UIEventType::ack:
buzzer.play("ack:d=32,o=8,b=120:c");
break;
case UIEventType::roomMessage:
case UIEventType::newContactMessage:
case UIEventType::none:
default:
break;
}
#endif
#ifdef PIN_VIBRATION
// Trigger vibration for all UI events except none
if (t != UIEventType::none) {
vibration.trigger();
}
#endif
}
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path, int8_t snr, uint8_t scope_idx) {
_msgcount = msgcount;
// --- Dedup: suppress retry spam (same sender + text within 60s) ---
uint32_t nameH = simpleHash(from_name);
uint32_t textH = simpleHash(text);
unsigned long now = millis();
for (int i = 0; i < MSG_DEDUP_SIZE; i++) {
if (_dedup[i].name_hash == nameH && _dedup[i].text_hash == textH &&
(now - _dedup[i].millis) < MSG_DEDUP_WINDOW_MS) {
// Duplicate — suppress UI notification but still queued for BLE sync
Serial.println("[Dedup] Suppressed duplicate");
return;
}
}
// Record this message in the dedup ring
_dedup[_dedupIdx].name_hash = nameH;
_dedup[_dedupIdx].text_hash = textH;
_dedup[_dedupIdx].millis = now;
_dedupIdx = (_dedupIdx + 1) % MSG_DEDUP_SIZE;
// Determine channel index by looking up the channel name
// For channel messages, from_name is the channel name
// For contact messages, from_name is the contact name (channel_idx = 0xFF)
uint8_t channel_idx = 0xFF; // Default: unknown/contact message
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && strcmp(ch.name, from_name) == 0) {
channel_idx = i;
break;
}
}
// --- Per-channel notification preference check ---
// Determines whether to suppress toast, buzzer, keyboard flash, vibration,
// display wake, and unread counter for this message. Messages are ALWAYS
// stored in history regardless -- only alerts and unread badges are gated.
bool suppressNotif = false;
{
int notifSlot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : (int)channel_idx;
if (notifSlot >= 0 && notifSlot < (int)sizeof(_node_prefs->channel_notif)) {
uint8_t pref = _node_prefs->channel_notif[notifSlot];
if (pref == NOTIF_NONE) {
suppressNotif = true;
} else if (pref == NOTIF_MENTIONS) {
// Check for @nodename or @[nodename] in message text (case-insensitive).
// MeshCore companion app sends mentions as @[node name] with brackets.
suppressNotif = true; // Suppress unless mention found
if (_node_prefs->node_name[0] != '\0') {
char tagPlain[36];
char tagBracket[38];
snprintf(tagPlain, sizeof(tagPlain), "@%s", _node_prefs->node_name);
snprintf(tagBracket, sizeof(tagBracket), "@[%s]", _node_prefs->node_name);
int lenPlain = strlen(tagPlain);
int lenBracket = strlen(tagBracket);
const char* p = text;
while (*p) {
if (strncasecmp(p, tagBracket, lenBracket) == 0 ||
strncasecmp(p, tagPlain, lenPlain) == 0) {
suppressNotif = false; // Mentioned -- notify
break;
}
p++;
}
}
}
}
}
// Set the flag for notify() which is called immediately after newMsg().
// If a custom notification tone is assigned and notifications are active,
// request MP3 playback and suppress the RTTTL buzzer so they don't overlap.
#if defined(LilyGo_TDeck_Pro_Max)
// Channel set to "Buzzer (vibrate)": pulse the motor instead of a tone.
if (!suppressNotif && notifSounds.isVibrateForChannel(channel_idx)) {
static DRV2605Haptic s_haptic;
static bool s_hapticReady = false;
if (!s_hapticReady) {
board.motorEnable();
delay(10); // let the motor rail settle before I2C
s_hapticReady = s_haptic.begin();
}
if (s_hapticReady) s_haptic.buzz(14); // 14 = Strong Buzz ~1s (effect 1 strong click was too brief)
s_lastMsgSuppressed = true; // suppress the default RTTTL buzzer
} else
#endif
{
#ifdef MECK_AUDIO_VARIANT
if (!suppressNotif) {
#ifdef MECK_40MHZ_TEST
const char* customSound = NULL; // MP3 tones gated -- default buzzer path runs
#else
const char* customSound = notifSounds.getSoundForChannel(channel_idx);
#endif
if (customSound && customSound[0] != '\0') {
char soundPath[48];
snprintf(soundPath, sizeof(soundPath), "/alarms/%s", customSound);
notifSounds.requestPlay(soundPath);
s_lastMsgSuppressed = true; // Suppress buzzer -- MP3 replaces it
} else {
s_lastMsgSuppressed = suppressNotif;
}
} else {
s_lastMsgSuppressed = suppressNotif;
}
#elif defined(HAS_4G_MODEM)
if (!suppressNotif) {
const char* customSound = notifSounds.getSoundForChannel(channel_idx);
if (customSound && customSound[0] != '\0') {
int8_t toneIdx = ModemManager::findToneByName(customSound);
if (toneIdx >= 0) {
modemManager.requestNotifTone(toneIdx);
}
s_lastMsgSuppressed = true; // Suppress buzzer -- modem tone replaces it
} else {
s_lastMsgSuppressed = suppressNotif;
}
} else {
s_lastMsgSuppressed = suppressNotif;
}
#else
s_lastMsgSuppressed = suppressNotif;
#endif
}
// Add to channel history screen with channel index, path data, and SNR
// For DMs (channel_idx == 0xFF):
// - Regular DMs: prefix text with sender name ("NodeName: hello")
// - Room server messages: text already contains "OriginalSender: message",
// don't double-prefix. Tag with room server name for conversation filtering.
bool isRoomMsg = false;
if (channel_idx == 0xFF) {
// Check if sender is a room server
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo senderContact;
for (uint32_t ci = 0; ci < numContacts; ci++) {
if (the_mesh.getContactByIdx(ci, senderContact) && strcmp(senderContact.name, from_name) == 0) {
if (senderContact.type == ADV_TYPE_ROOM) isRoomMsg = true;
break;
}
}
if (isRoomMsg) {
// Room server: text already has "Poster: message" format — store as-is
// Tag with room server name for conversation filtering
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, from_name, suppressNotif);
} else {
// Regular DM: prefix with sender name
char dmFormatted[CHANNEL_MSG_TEXT_LEN];
snprintf(dmFormatted, sizeof(dmFormatted), "%s: %s", from_name, text);
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, dmFormatted, path, snr, nullptr, suppressNotif);
}
} else {
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, nullptr, suppressNotif, scope_idx);
}
// If user is currently viewing this channel on the device, or companion
// app is connected (they'll see it there), mark as read immediately
if ((isOnChannelScreen() &&
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) ||
hasConnection()) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
}
// Per-contact DM unread tracking: find contact index by name
// Skip increment when companion app is connected (user sees DMs there)
if (channel_idx == 0xFF && _dmUnread && !hasConnection()) {
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo contact;
for (uint32_t ci = 0; ci < numContacts; ci++) {
if (the_mesh.getContactByIdx(ci, contact) && strcmp(contact.name, from_name) == 0) {
if (_dmUnread[ci] < 255) _dmUnread[ci]++;
break;
}
}
}
// Don't interrupt user with popup - just show brief notification
// Messages are stored in channel history, accessible via tile/key
// Suppress toasts for room server messages (bulk sync would spam toasts)
#if defined(LilyGo_TDeck_Pro)
// No toasts over a running Game Boy game either: on a busy mesh they would
// be constant. The header on that screen shows the unread count instead.
// Snake and Minesweeper are other screens and keep their toasts.
const bool gbcPlaying = isOnGBCScreen() && ((GBCEmulatorScreen*)gbc_screen)->isRunning();
#else
const bool gbcPlaying = false;
#endif
if (!isOnRepeaterAdmin() && !isRoomMsg && !suppressNotif && !gbcPlaying) {
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
}
// Ensure picker badges update after toaster clears
if (isOnChannelPickerScreen()) {
forceRefresh();
}
if (_display != NULL && !suppressNotif) {
if (!_display->isOn() && !hasConnection()) {
_display->turnOn();
}
if (_display->isOn()) {
_auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer
// Throttle refresh during room sync — batch messages instead of 648ms render per msg
if (isRoomMsg) {
unsigned long earliest = millis() + 3000; // At most one refresh per 3s during sync
if (_next_refresh < earliest) _next_refresh = earliest;
} else {
_next_refresh = 100; // trigger refresh
}
}
}
// Keyboard flash notification (suppress for room sync and muted channels)
#ifdef KB_BL_PIN
if (_node_prefs->kb_flash_notify && !isRoomMsg && !suppressNotif) {
kbBacklightFlashWrite(true);
_kb_flash_off_at = millis() + 200; // 200ms flash
}
#endif
}
void UITask::userLedHandler() {
#ifdef PIN_STATUS_LED
int cur_time = millis();
if (cur_time > next_led_change) {
if (led_state == 0) {
led_state = 1;
if (_msgcount > 0) {
last_led_increment = LED_ON_MSG_MILLIS;
} else {
last_led_increment = LED_ON_MILLIS;
}
next_led_change = cur_time + last_led_increment;
} else {
led_state = 0;
next_led_change = cur_time + LED_CYCLE_MILLIS - last_led_increment;
}
digitalWrite(PIN_STATUS_LED, led_state == LED_STATE_ON);
}
#endif
}
#ifndef EINK_FAST_FULL_EVERY
#define EINK_FAST_FULL_EVERY 20
#endif
void UITask::setCurrScreen(UIScreen* c) {
#if defined(LilyGo_TDeck_Pro) && defined(EINK_FASTLUT_EXPERIMENT)
// Fast-waveform housekeeping: the register waveform pushes every pixel on
// every partial and builds up ghosting and ink imbalance. After
// EINK_FAST_FULL_EVERY fast partials, make the redraw that comes with this
// screen change a full refresh, so the flash lands where a redraw is
// happening anyway rather than mid-typing.
if (_display != NULL) {
GxEPDDisplay* gd = static_cast<GxEPDDisplay*>(_display);
if (gd->fastPartialsSinceFull() >= EINK_FAST_FULL_EVERY) gd->requestFullRefresh();
}
#endif
curr = c;
_alert_expiry = 0; // Dismiss any active toast — prevents stale overlay from
// triggering extra 644ms e-ink refreshes on the new screen
if (_hintActive) dismissBootHint(); // Dismiss hint when navigating away
_next_refresh = 100;
}
/*
hardware-agnostic pre-shutdown activity should be done here
*/
void UITask::shutdown(bool restart){
#ifdef PIN_BUZZER
/* note: we have a choice here -
we can do a blocking buzzer.loop() with non-deterministic consequences
or we can set a flag and delay the shutdown for a couple of seconds
while a non-blocking buzzer.loop() plays out in UITask::loop()
*/
buzzer.shutdown();
uint32_t buzzer_timer = millis(); // fail-safe shutdown
while (buzzer.isPlaying() && (millis() - 2500) < buzzer_timer)
buzzer.loop();
#endif // PIN_BUZZER
if (restart) {
_board->reboot();
} else {
// Disable BLE if active
if (_serial != NULL && _serial->isEnabled()) {
_serial->disable();
}
// Disable WiFi if active
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
#endif
// Disable 4G modem if active
#ifdef HAS_4G_MODEM
modemManager.shutdown();
#endif
// Disable GPS if active
#if ENV_INCLUDE_GPS == 1
{
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
_sensors->setSettingValue("gps", "0");
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
#if defined(LilyGo_TDeck_Pro_Max)
board.gpsPowerOff(); // MAX: GPS power is XL9555-routed, not PIN_GPS_EN
#endif
}
}
#endif
// Power off LoRa radio, display, and board
radio_driver.powerOff();
_display->turnOff();
// BQ25896 ship mode: disconnect battery from VSYS entirely.
// Must happen BEFORE _board->powerOff() cuts PIN_PERF_POWERON
// (I2C pull-ups need VDD3V3 to complete the transaction).
// TI recommends: set BATFET_DLY=1 first, then BATFET_DIS=1 as
// the last I2C write to avoid bricking the I2C state machine.
// After tSM_DLY (~10-15s) the BATFET opens during deep sleep.
// Wake: USB-C plug-in only (no reset button -- no power to ESP32).
#if defined(LilyGo_TDeck_Pro_Max)
if (_full_poweroff) {
// MAX uses the SY6970 charger @ 0x6A (the BQ25896 @ 0x6B is absent --
// I2C probe ACKs only at 0x6A). XPowersLib's SY6970 shutdown() closes the
// battery path by setting REG09 bit 5 (BATFET_DIS) via read-modify-write,
// with no BATFET_DLY. Mirror that exactly here so charger plug-in re-wakes
// the board after a full power-off.
Wire.beginTransmission(I2C_ADDR_SY6970);
Wire.write(0x09);
Wire.endTransmission(false);
Wire.requestFrom((uint8_t)I2C_ADDR_SY6970, (uint8_t)1);
uint8_t reg09 = Wire.read();
Wire.beginTransmission(I2C_ADDR_SY6970);
Wire.write(0x09);
Wire.write(reg09 | 0x20); // BATFET_DIS = bit 5
Wire.endTransmission();
}
#elif defined(I2C_ADDR_BQ25896)
if (_full_poweroff) {
Wire.beginTransmission(I2C_ADDR_BQ25896);
Wire.write(0x09);
Wire.endTransmission(false);
Wire.requestFrom((uint8_t)I2C_ADDR_BQ25896, (uint8_t)1);
uint8_t reg09 = Wire.read();
// Step 1: set BATFET_DLY=1 (bit 3) for safe I2C completion
Wire.beginTransmission(I2C_ADDR_BQ25896);
Wire.write(0x09);
Wire.write(reg09 | 0x08); // BATFET_DLY = bit 3
Wire.endTransmission();
// Step 2: set BATFET_DIS=1 (bit 5) -- MUST be the last I2C write
Wire.beginTransmission(I2C_ADDR_BQ25896);
Wire.write(0x09);
Wire.write(reg09 | 0x28); // BATFET_DIS (0x20) | BATFET_DLY (0x08)
Wire.endTransmission();
}
#endif
_board->powerOff();
}
}
bool UITask::isButtonPressed() const {
#if (defined(PIN_USER_BTN))
return user_btn.isPressed();
#else
return false;
#endif
}
void UITask::loop() {
char c = 0;
#if (defined(PIN_USER_BTN))
int ev = user_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
#if defined(LilyGo_TDeck_Pro)
// T-Deck Pro: single click ignored while locked — double-press to unlock
if (_locked) {
c = 0;
} else {
c = checkDisplayOn(KEY_NEXT);
}
#else
c = checkDisplayOn(KEY_NEXT);
#endif
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
c = handleLongPress(KEY_ENTER);
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
c = handleDoubleClick(KEY_PREV);
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
c = handleTripleClick(KEY_SELECT);
}
#endif
#if defined(PIN_USER_BTN_ANA)
if (abs(millis() - _analogue_pin_read_millis) > 10) {
ev = analog_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
c = checkDisplayOn(KEY_NEXT);
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
c = handleLongPress(KEY_ENTER);
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
c = handleDoubleClick(KEY_PREV);
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
c = handleTripleClick(KEY_SELECT);
}
_analogue_pin_read_millis = millis();
}
#endif
#if defined(BACKLIGHT_BTN)
if (millis() > next_backlight_btn_check) {
bool touch_state = digitalRead(PIN_BUTTON2);
#if defined(DISP_BACKLIGHT)
digitalWrite(DISP_BACKLIGHT, !touch_state);
#elif defined(EXP_PIN_BACKLIGHT)
expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state);
#endif
next_backlight_btn_check = millis() + 300;
}
#endif
if (c != 0 && curr) {
// Dismiss boot hint on any button input
if (_hintActive) {
dismissBootHint();
c = 0; // Consume the press
}
}
if (c != 0 && curr) {
curr->handleInput(c);
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_next_refresh = 100; // trigger refresh
#if defined(LilyGo_TDeck_Pro)
_lastInputMillis = millis(); // Reset auto-lock idle timer
#endif
}
userLedHandler();
// Turn off keyboard flash after timeout
#ifdef KB_BL_PIN
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
#ifdef HAS_4G_MODEM
// Don't turn off LED if incoming call flash is active
if (!_incomingCallRinging) {
kbBacklightFlashWrite(false);
}
#else
kbBacklightFlashWrite(false);
#endif
_kb_flash_off_at = 0;
}
#endif
// Incoming call LED flash — rapid repeated pulse while ringing
#if defined(HAS_4G_MODEM) && defined(KB_BL_PIN)
{
bool ringing = modemManager.isRinging();
if (ringing && !_incomingCallRinging) {
// Ringing just started
_incomingCallRinging = true;
_callFlashState = false;
_nextCallFlash = 0; // Start immediately
// Wake display for incoming call
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + 60000; // Keep display on while ringing (60s)
} else if (!ringing && _incomingCallRinging) {
// Ringing stopped
_incomingCallRinging = false;
// Only turn off LED if message flash isn't also active
if (!_kb_flash_off_at) {
kbBacklightFlashWrite(false);
}
_callFlashState = false;
}
// Rapid LED flash while ringing (if kb_flash_notify is ON)
if (_incomingCallRinging && _node_prefs->kb_flash_notify) {
unsigned long now = millis();
if (now >= _nextCallFlash) {
_callFlashState = !_callFlashState;
kbBacklightFlashWrite(_callFlashState);
// 250ms on, 250ms off — fast pulse to distinguish from single msg flash
_nextCallFlash = now + 250;
}
// Extend auto-off while ringing
_auto_off = millis() + 60000;
}
}
#endif
#ifdef PIN_BUZZER
if (buzzer.isPlaying()) buzzer.loop();
#endif
if (curr) curr->poll();
if (_display != NULL && _display->isOn()) {
if (millis() >= _next_refresh && curr) {
// Defer display refresh while BLE is actively transferring contacts.
// E-ink partial update blocks for ~820ms, stalling the BLE send queue
// and adding ~1.6s of dead time to a full contact sync.
if (_notifAudioActive) {
_next_refresh = millis() + 200; // Defer e-ink refresh during notif tone (SPI bus contention)
} else if (_serial != NULL && _serial->hasPendingData()) {
_next_refresh = millis() + 500; // Re-check in 500ms
} else {
// Sync dark mode with prefs (settings toggle takes effect here)
#if defined(LilyGo_TDeck_Pro)
if (_node_prefs && display.isDarkMode() != (_node_prefs->dark_mode != 0)) {
display.setDarkMode(_node_prefs->dark_mode != 0);
}
#endif
// Sync font style with prefs (settings toggle takes effect here)
#if defined(LilyGo_TDeck_Pro)
if (_node_prefs && display.getFontStyle() != _node_prefs->ui_font_style) {
display.setFontStyle(_node_prefs->ui_font_style);
}
#endif
_display->startFrame();
int delay_millis = curr->render(*_display);
if (_hintActive && millis() < _hintExpiry) {
// Boot navigation hint overlay — multi-line, larger box
_display->setTextSize(1);
int w = _display->width();
int h = _display->height();
int boxX = w / 8;
int boxY = h / 5;
int boxW = w - boxX * 2;
int boxH = h * 3 / 5;
_display->setColor(DisplayDriver::DARK);
_display->fillRect(boxX, boxY, boxW, boxH);
_display->setColor(DisplayDriver::LIGHT);
_display->drawRect(boxX, boxY, boxW, boxH);
int cx = w / 2;
int lineH = 11;
int startY = boxY + 6;
_display->drawTextCentered(cx, startY, "M:Msgs C:Contacts");
_display->drawTextCentered(cx, startY + lineH, "S:Settings E:Reader");
_display->drawTextCentered(cx, startY + lineH * 2, "N:Notes W/S:Scroll");
_display->drawTextCentered(cx, startY + lineH * 3, "A/D:Cycle Left/Right");
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[X to dismiss]");
_next_refresh = _hintExpiry;
} else if (_hintActive) {
// Hint expired — auto-dismiss
dismissBootHint();
_next_refresh = millis() + 200;
} else if (millis() < _alert_expiry) { // render alert popup
_display->setTextSize(1);
int y = _display->height() / 3;
int p = _display->height() / 32;
_display->setColor(DisplayDriver::DARK);
_display->fillRect(p, y, _display->width() - p*2, y);
_display->setColor(DisplayDriver::LIGHT); // draw box border
_display->drawRect(p, y, _display->width() - p*2, y);
_display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
_next_refresh = _alert_expiry; // will need refresh when alert is dismissed
} else {
_next_refresh = millis() + delay_millis;
}
_display->endFrame();
// E-ink render throttle: enforce minimum interval between renders.
// Partial update blocks for ~644ms; full refresh blocks for ~3000ms.
// Without this floor, changing readings (battery, uptime) trigger
// back-to-back renders that cause continuous flashing.
#ifdef EINK_FULL_REFRESH_ONLY
unsigned long minNext = millis() + 300000; // Full refresh: 5 min idle
#else
unsigned long minNext = millis() + 800; // Partial refresh: 800ms floor
#endif
#if defined(LilyGo_TDeck_Pro)
// Game Boy emulator: no floor while a game runs. The picture changes
// every frame, so the panel should refresh back-to-back at its own
// partial-refresh rate rather than pause 800 ms between pictures.
if (isOnGBCScreen() && ((GBCEmulatorScreen*)gbc_screen)->isRunning()) minNext = millis();
#endif
if (_next_refresh < minNext) _next_refresh = minNext;
// Toast dismissal must not be starved by the e-ink render throttle:
// any render inside the alert window (e.g. a DM status push) re-draws
// the overlay and the floor above can push the scheduled clearing
// render well past _alert_expiry, leaving the toast on screen. If an
// alert is still active and due to lapse before the next scheduled
// render, wake at expiry so the clearing frame lands on time.
if (_alert_expiry != 0 && millis() < _alert_expiry && _next_refresh > _alert_expiry) {
_next_refresh = _alert_expiry;
}
} // end else (not bulk syncing)
}
#if AUTO_OFF_MILLIS > 0
if (millis() > _auto_off) {
_display->turnOff();
}
#endif
}
// Auto-lock idle timer — runs regardless of display on/off state
#if defined(LilyGo_TDeck_Pro)
if (_node_prefs && _node_prefs->auto_lock_minutes > 0 && !_locked) {
uint8_t alm = _node_prefs->auto_lock_minutes;
// Only act on valid option values (guards against garbage from uninitialised prefs)
if (alm == 2 || alm == 5 || alm == 10 || alm == 15 || alm == 30) {
unsigned long lock_timeout = (unsigned long)alm * 60000UL;
if (millis() - _lastInputMillis >= lock_timeout) {
lockScreen();
}
}
}
// Lock screen clock refresh — keeps the displayed time current.
// T-Deck Pro: every 1 minute.
// Wakes the display driver briefly to render, then auto-off handles it.
#if defined(LilyGo_TDeck_Pro)
// T-Deck Pro: refresh every 1 minute
if (_locked && _display != NULL) {
const unsigned long LOCK_REFRESH_INTERVAL = 1UL * 60UL * 1000UL; // 1 minute
#else
if (_locked && _display != NULL) {
const unsigned long LOCK_REFRESH_INTERVAL = 2UL * 60UL * 1000UL; // 2 minutes
#endif
if (millis() - _lastLockRefresh >= LOCK_REFRESH_INTERVAL) {
_lastLockRefresh = millis();
if (!_display->isOn()) {
_display->turnOn();
_auto_off = millis() + 5000; // Stay on just long enough to render + settle
}
_next_refresh = 0; // Trigger immediate render
}
}
#endif
#ifdef PIN_VIBRATION
vibration.loop();
#endif
#ifdef AUTO_SHUTDOWN_MILLIVOLTS
if (millis() > next_batt_chck) {
uint16_t milliVolts = getBattMilliVolts();
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
_low_batt_count++;
if (_low_batt_count >= 3) { // 3 consecutive low readings (~24s) to avoid transient sags
// show low battery shutdown alert on e-ink (persists after power loss)
#if defined(LilyGo_TDeck_Pro)
if (_display != NULL) {
_display->startFrame();
_display->setTextSize(2);
_display->setColor(DisplayDriver::RED);
_display->drawTextCentered(_display->width() / 2, 20, "Low Battery.");
_display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!");
_display->endFrame();
}
#endif
// Flush contacts before the battery dies -- the chunked lazy save may not
// have run for hours. Blocking save (tmp-then-rename, brownout-safe).
the_mesh.saveContacts();
shutdown();
}
} else {
_low_batt_count = 0;
}
next_batt_chck = millis() + 8000;
}
#endif
#if defined(LilyGo_TDeck_Pro_Max)
// TEMPORARY: charger + fuel gauge readout on serial every 10 s (read-only)
{
static unsigned long nextChargerDump = 0;
if ((long)(millis() - nextChargerDump) >= 0) {
nextChargerDump = millis() + 10000;
board.chargerDebugPrint();
}
}
#endif
}
char UITask::checkDisplayOn(char c) {
if (_display != NULL) {
if (!_display->isOn()) {
_display->turnOn(); // turn display on and consume event
c = 0;
}
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_next_refresh = 0; // trigger refresh
#if defined(LilyGo_TDeck_Pro)
_lastInputMillis = millis(); // Reset auto-lock idle timer
#endif
}
return c;
}
char UITask::handleLongPress(char c) {
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
the_mesh.enterCLIRescue();
c = 0; // consume event
}
return c;
}
char UITask::handleDoubleClick(char c) {
MESH_DEBUG_PRINTLN("UITask: double click triggered");
#if defined(LilyGo_TDeck_Pro)
// Double-click boot button → lock/unlock screen
if (_locked) {
unlockScreen();
} else {
lockScreen();
}
c = 0;
#endif
checkDisplayOn(c);
return c;
}
char UITask::handleTripleClick(char c) {
MESH_DEBUG_PRINTLN("UITask: triple click triggered");
checkDisplayOn(c);
toggleBuzzer();
c = 0;
return c;
}
#if defined(LilyGo_TDeck_Pro)
void UITask::lockScreen() {
if (_locked) return;
_locked = true;
_screenBeforeLock = curr;
setCurrScreen(lock_screen);
// Ensure display is on so lock screen renders (auto-off may have turned it off)
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
#if defined(LilyGo_TDeck_Pro_Max)
// MAX: IO41 frontlight stays lit otherwise, burning power behind the
// lock screen. Plain Pro V1.1 has no working frontlight to switch off.
if (board.isBacklightOn()) board.backlightOff();
#endif
_next_refresh = 0; // Draw lock screen immediately
_auto_off = millis() + 60000; // 60s before display off while locked
_lastLockRefresh = millis(); // Start lock screen clock refresh cycle
Serial.println("[UI] Screen locked — entering low-power mode");
}
void UITask::unlockScreen() {
if (!_locked) return;
_locked = false;
if (_screenBeforeLock) {
setCurrScreen(_screenBeforeLock);
} else {
gotoHomeScreen();
}
_screenBeforeLock = nullptr;
// Ensure display is on so unlocked screen renders
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_lastInputMillis = millis(); // Reset auto-lock idle timer
_next_refresh = 0;
Serial.println("[UI] Screen unlocked — exiting low-power mode");
}
#endif // LilyGo_TDeck_Pro
bool UITask::getGPSState() {
#if ENV_INCLUDE_GPS == 1
return _node_prefs != NULL && _node_prefs->gps_enabled;
#else
return false;
#endif
}
void UITask::toggleGPS() {
#if ENV_INCLUDE_GPS == 1
if (_sensors != NULL) {
if (_node_prefs->gps_enabled) {
// Disable GPS — cut hardware power
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
#if defined(LilyGo_TDeck_Pro_Max)
board.gpsPowerOff(); // MAX: GPS power is XL9555-routed, not PIN_GPS_EN
#endif
notify(UIEventType::ack);
} else {
#ifdef MECK_40MHZ_TEST
// 40 MHz test build: GPS is gated -- refuse the enable, leave rail off
showAlert("GPS gated (40MHz build)", 1200);
return;
#endif
// Enable GPS — power on hardware
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
#endif
#if defined(LilyGo_TDeck_Pro_Max)
board.gpsPowerOn(); // MAX: GPS power is XL9555-routed, not PIN_GPS_EN
#endif
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
}
#endif
}
void UITask::toggleBuzzer() {
// Toggle buzzer quiet mode
#ifdef PIN_BUZZER
if (buzzer.isQuiet()) {
buzzer.quiet(false);
notify(UIEventType::ack);
} else {
buzzer.quiet(true);
}
_node_prefs->buzzer_quiet = buzzer.isQuiet();
the_mesh.savePrefs();
showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800);
_next_refresh = 0; // trigger refresh
#endif
}
void UITask::injectKey(char c) {
if (c != 0 && curr) {
// Turn on display if it's off
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
curr->handleInput(c);
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
#if defined(LilyGo_TDeck_Pro)
_lastInputMillis = millis(); // Reset auto-lock idle timer
#endif
// Debounce refresh when editing UTC offset - e-ink takes 644ms per refresh
// so don't queue another render until the current one could have finished
if (isEditingHomeScreen()) {
unsigned long earliest = millis() + 700;
if (_next_refresh < earliest) {
_next_refresh = earliest;
}
}
#ifdef EINK_FULL_REFRESH_ONLY
// Full-refresh displays (SSD1681): debounce printable character input.
// Compose typing (0x20-0x7E) pushes the render 2.5s into the future so
// the user can type a whole word before a ~2.2s full refresh fires.
// Navigation/special keys (arrows, enter, escape, etc.) refresh
// immediately so scrolling and screen changes remain responsive.
else if ((unsigned char)c >= 0x20 && (unsigned char)c <= 0x7E) {
unsigned long earliest = millis() + 2500;
if (_next_refresh < earliest) _next_refresh = earliest;
} else {
_next_refresh = 100; // navigation key — refresh now
}
#else
else {
_next_refresh = 100; // trigger refresh
}
#endif
}
}
void UITask::gotoHomeScreen() {
// Cancel any active editing state when navigating to home
((HomeScreen *) home)->cancelEditing();
setCurrScreen(home);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
// Activate deferred boot hint now that home screen is visible
if (_pendingBootHint) {
_pendingBootHint = false;
_hintActive = true;
_hintExpiry = millis() + 8000; // 8 seconds auto-dismiss
_next_refresh = millis() + 100;
Serial.println("[UI] Boot hint activated");
}
}
bool UITask::isEditingHomeScreen() const {
return curr == home && (((HomeScreen *) home)->isEditingUTC() ||
((HomeScreen *) home)->isEditingTimezone());
}
bool UITask::isHomeOnRecentPage() const {
return curr == home && ((HomeScreen *) home)->isOnRecentPage();
}
bool UITask::isHomeOnShutdownPage() const {
return curr == home && ((HomeScreen *) home)->isOnShutdownPage();
}
bool UITask::isHomeOnTimezonesPage() const {
return curr == home && ((HomeScreen *) home)->isOnTimezonesPage();
}
void UITask::selectHomeTimezoneRowAt(int vy) {
if (curr == home) ((HomeScreen *) home)->selectTimezoneRowAt(vy);
}
void UITask::gotoChannelScreen(bool resetDmView) {
ChannelScreen* cs = (ChannelScreen*)channel_screen;
// If currently showing DM view, reset to channel 0 (unless caller opts out)
if (resetDmView && cs->getViewChannelIdx() == 0xFF) {
cs->setViewChannelIdx(0);
}
cs->resetScroll();
// Mark the currently viewed channel as read
cs->markChannelRead(cs->getViewChannelIdx());
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoChannelPickerScreen() {
ChannelScreen* cs = (ChannelScreen*)channel_screen;
ChannelPickerScreen* pick = (ChannelPickerScreen*)channel_picker_screen;
pick->enter(cs->getViewChannelIdx());
setCurrScreen(channel_picker_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoDMTab() {
((ChannelScreen *) channel_screen)->setViewChannelIdx(0xFF); // switches + marks read
((ChannelScreen *) channel_screen)->resetScroll();
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoDMConversation(const char* contactName, int contactIdx, uint8_t perms) {
ChannelScreen* cs = (ChannelScreen*)channel_screen;
cs->setViewChannelIdx(0xFF); // enters inbox mode + marks read
cs->openConversation(contactName, contactIdx, perms); // switches to conversation mode
cs->resetScroll();
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoContactsScreen() {
((ContactsScreen *) contacts_screen)->resetScroll();
setCurrScreen(contacts_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoTextReader() {
if (!text_reader) return; // Not available on this platform
TextReaderScreen* reader = (TextReaderScreen*)text_reader;
if (_display != NULL) {
reader->enter(*_display);
}
setCurrScreen(text_reader);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoNotesScreen() {
if (!notes_screen) return; // Not available on this platform
NotesScreen* notes = (NotesScreen*)notes_screen;
if (_display != NULL) {
notes->enter(*_display);
}
// Set fresh timestamp and wire up time getter for note creation
notes->setTimestamp(rtc_clock.getCurrentTime(),
_node_prefs ? _node_prefs->utc_offset_hours : 0);
notes->setTimeGetter([]() -> uint32_t { return rtc_clock.getCurrentTime(); });
setCurrScreen(notes_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoSettingsScreen() {
((SettingsScreen *) settings_screen)->enter();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoOnboarding() {
((SettingsScreen *) settings_screen)->enterOnboarding();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoAudiobookPlayer() {
#ifdef MECK_AUDIO_VARIANT
if (audiobook_screen == nullptr) return; // No audio hardware
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
if (_display != NULL) {
abPlayer->enter(*_display);
}
setCurrScreen(audiobook_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
#endif
}
#ifdef MECK_AUDIO_VARIANT
void UITask::gotoAlarmScreen() {
if (alarm_screen == nullptr) return;
AlarmScreen* alarmScr = (AlarmScreen*)alarm_screen;
if (_display != NULL) {
alarmScr->enter(*_display);
}
setCurrScreen(alarm_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoVoiceScreen() {
if (voice_screen == nullptr) return;
VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)voice_screen;
if (_display != NULL) {
voiceScr->enter(*_display);
}
setCurrScreen(voice_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
#ifdef HAS_4G_MODEM
void UITask::gotoSMSScreen() {
SMSScreen* smsScr = (SMSScreen*)sms_screen;
// activate() resets the view to the app menu, which would wipe the ringing
// or in-call screen that onCallEvent() has just put up. Skip it mid-call.
if (!smsScr->isInCallView()) {
smsScr->activate();
}
setCurrScreen(sms_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
uint8_t UITask::getChannelScreenViewIdx() const {
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
}
int UITask::getUnreadMsgCount() const {
return ((ChannelScreen *) channel_screen)->getTotalUnread();
}
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
// Format the message as "Sender: message"
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
// Tag the bubble with this send's fingerprint so the "Heard by" overlay can
// later match it to the repeat track (MyMesh::getHeardBy).
uint8_t fp[12]; // SENT_FINGERPRINT_SIZE
bool haveFp = the_mesh.getLastSentFingerprint(fp);
// Add to channel history with path_len=0 (local message)
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg,
nullptr, 0, nullptr, false, 0xFF, 0, 0,
haveFp ? fp : nullptr);
}
void UITask::addSentDM(const char* recipientName, const char* sender, const char* text,
uint32_t send_ref, uint8_t send_total) {
// Format as "Sender: message" and tag with recipient's peer hash
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
((ChannelScreen *) channel_screen)->addMessage(0xFF, 0, sender, formattedMsg,
nullptr, 0, recipientName,
false, 0xFF, send_ref, send_total);
}
void UITask::dmSendStatus(uint32_t send_ref, uint8_t status, uint8_t attempt, uint8_t total) {
if (((ChannelScreen *) channel_screen)->setSendStatus(send_ref, status, attempt, total)) {
// Repaint promptly if the user is looking at the conversation
if (isOnChannelScreen()) forceRefresh();
}
}
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
// If clearing DMs, also zero all per-contact DM counts
if (channel_idx == 0xFF && _dmUnread) {
memset(_dmUnread, 0, MAX_CONTACTS * sizeof(uint8_t));
}
// Trigger a refresh so the home screen unread count updates in real-time
_next_refresh = millis() + 200;
}
void UITask::markAllChannelsRead() {
((ChannelScreen *) channel_screen)->markAllRead();
if (_dmUnread) {
memset(_dmUnread, 0, MAX_CONTACTS * sizeof(uint8_t));
}
_next_refresh = millis() + 200;
}
bool UITask::hasDMUnread(int contactIdx) const {
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return false;
return _dmUnread[contactIdx] > 0;
}
int UITask::getDMUnreadCount(int contactIdx) const {
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return 0;
return _dmUnread[contactIdx];
}
void UITask::clearDMUnread(int contactIdx) {
if (!_dmUnread || contactIdx < 0 || contactIdx >= MAX_CONTACTS) return;
int count = _dmUnread[contactIdx];
if (count > 0) {
_dmUnread[contactIdx] = 0;
((ChannelScreen *) channel_screen)->subtractDMUnread(count);
_next_refresh = millis() + 200;
}
}
void UITask::gotoRepeaterAdmin(int contactIdx) {
// Lazy-initialize on first use (same pattern as audiobook player)
if (repeater_admin == nullptr) {
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
}
// Get contact name for the screen header
ContactInfo contact;
char name[32] = "Unknown";
if (the_mesh.getContactByIdx(contactIdx, contact)) {
strncpy(name, contact.name, sizeof(name) - 1);
name[sizeof(name) - 1] = '\0';
}
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
admin->openForContact(contactIdx, name);
setCurrScreen(repeater_admin);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoRepeaterAdminDirect(int contactIdx) {
// Open admin and auto-submit cached password (skips password screen)
_skipRoomRedirect = true; // Don't redirect back to conversation after login
gotoRepeaterAdmin(contactIdx);
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
if (admin && admin->getState() == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
// If password was pre-filled from cache, simulate Enter to submit login
admin->handleInput('\r');
}
}
void UITask::gotoPathEditor(int contactIdx) {
// Lazy-initialize on first use
if (path_editor == nullptr) {
path_editor = new PathEditorScreen(this, &rtc_clock);
}
PathEditorScreen* editor = (PathEditorScreen*)path_editor;
editor->openForContact(contactIdx);
setCurrScreen(path_editor);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoDiscoveryScreen() {
((DiscoveryScreen*)discovery_screen)->resetScroll();
setCurrScreen(discovery_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoLastHeardScreen() {
((LastHeardScreen*)last_heard_screen)->resetScroll();
setCurrScreen(last_heard_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoRxLogScreen() {
((RxLogScreen*)rxlog_screen)->resetScroll();
setCurrScreen(rxlog_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoTraceScreen() {
TraceScreen* ts = (TraceScreen*)trace_screen;
ts->enter(the_mesh.getNodePrefs()->path_hash_mode);
setCurrScreen(trace_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoGamesMenu() {
GamesMenuScreen* gm = (GamesMenuScreen*)games_menu_screen;
gm->enter();
setCurrScreen(games_menu_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoSnakeScreen() {
SnakeScreen* ss = (SnakeScreen*)snake_screen;
ss->enter();
setCurrScreen(snake_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoMinesweeperScreen() {
MinesweeperScreen* ms = (MinesweeperScreen*)minesweeper_screen;
ms->enter();
setCurrScreen(minesweeper_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#if defined(LilyGo_TDeck_Pro)
void UITask::gotoGBCScreen() {
#ifdef MECK_40MHZ_TEST
// The emulator needs 240 MHz; at 40 it is unusable. Refuse from every
// launch route (tile, J-key, games menu) and stay put. Snake and
// Minesweeper are fine at 40 MHz and are not gated.
showAlert("Game Boy needs 240MHz", 1500);
return;
#endif
GBCEmulatorScreen* gb = (GBCEmulatorScreen*)gbc_screen;
gb->enter();
setCurrScreen(gbc_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
void UITask::onTraceResult(uint32_t tag, uint8_t flags, const uint8_t* path_snrs,
const uint8_t* path_hashes, uint8_t path_len, int8_t final_snr) {
TraceScreen* ts = (TraceScreen*)trace_screen;
if (ts) {
ts->onTraceResult(tag, flags, path_snrs, path_hashes, path_len, final_snr);
_next_refresh = 100; // Force refresh to show results
}
}
#ifdef MECK_WEB_READER
void UITask::gotoWebReader() {
// Lazy-initialize on first use (same pattern as audiobook player)
if (web_reader == nullptr) {
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
web_reader = new WebReaderScreen(this, _node_prefs);
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
}
WebReaderScreen* wr = (WebReaderScreen*)web_reader;
if (_display != NULL) {
wr->enter(*_display);
}
// Heap diagnostic — check state after web reader entry (WiFi connects later)
Serial.printf("[HEAP] WebReader enter - free: %u, largest: %u, PSRAM: %u\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap(), ESP.getFreePsram());
setCurrScreen(web_reader);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
#if HAS_GPS
void UITask::gotoMapScreen() {
if (!map_screen) return; // Not available on this platform
MapScreen* map = (MapScreen*)map_screen;
if (_display != NULL) {
map->enter(*_display);
}
setCurrScreen(map_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
_next_refresh = 100; // trigger re-render
if (success) {
int cidx = ((RepeaterAdminScreen*)repeater_admin)->getContactIdx();
if (cidx >= 0) {
clearDMUnread(cidx);
// Room server login: redirect to conversation view with stored permissions.
// Admin users see L:Admin footer to access the admin panel.
// Skip redirect if user explicitly pressed L to get to admin.
if (!_skipRoomRedirect) {
ContactInfo contact;
if (the_mesh.getContactByIdx(cidx, contact) && contact.type == ADV_TYPE_ROOM) {
uint8_t maskedPerms = permissions & 0x03;
gotoDMConversation(contact.name, cidx, maskedPerms);
return;
}
}
_skipRoomRedirect = false;
}
}
}
}
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
_next_refresh = 100; // trigger re-render
}
}
void UITask::onAdminTelemetryResult(const uint8_t* data, uint8_t len) {
Serial.printf("[UITask] onAdminTelemetryResult: %d bytes, onAdmin=%d\n", len, isOnRepeaterAdmin());
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onTelemetryResult(data, len);
_next_refresh = 100; // trigger re-render
}
}
#ifdef MECK_AUDIO_VARIANT
bool UITask::isAudioPlayingInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isAudioActive();
}
bool UITask::isAudioPausedInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isBookOpen() && !player->isAudioActive();
}
#endif