#include "UITask.h" #include #include "../MyMesh.h" #if !defined(LILYGO_TECHO_LITE) && !defined(LILYGO_TECHO_CARD) #include "NotesScreen.h" #endif #include "RepeaterAdminScreen.h" #include "PathEditorScreen.h" #include "DiscoveryScreen.h" #include "LastHeardScreen.h" #include "Tracescreen.h" #include "GamesMenuScreen.h" #include "SnakeScreen.h" #include "MinesweeperScreen.h" #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif #if HAS_GPS && !defined(LILYGO_TECHO_CARD) #include "MapScreen.h" #endif #include "target.h" #if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_AUDIO_VARIANT) #include "HomeIcons.h" #endif #if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION) #include #endif #if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION) #include "esp_sleep.h" #endif #ifndef AUTO_OFF_MILLIS #define AUTO_OFF_MILLIS 15000 // 15 seconds #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 #if defined(LilyGo_T5S3_EPaper_Pro) #define UI_RECENT_LIST_SIZE 8 #else #define UI_RECENT_LIST_SIZE 4 #endif #endif #define PRESS_LABEL "long press" #include "icons.h" #include "ChannelScreen.h" #include "ChannelPickerScreen.h" #include "ContactsScreen.h" #if !defined(LILYGO_TECHO_LITE) && !defined(LILYGO_TECHO_CARD) #include "TextReaderScreen.h" #endif #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; 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, #elif defined(MECK_WIFI_COMPANION) WIFI_STATUS, #endif ADVERT, #if ENV_INCLUDE_GPS == 1 GPS, #endif #if UI_SENSORS_PAGE == 1 SENSORS, #endif #if HAS_BQ27220 BATTERY, #endif SHUTDOWN, 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 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()); #if defined(LilyGo_T5S3_EPaper_Pro) // T5S3: text-only battery indicator — "Batt 99% 4.1v" char battStr[20]; float volts = batteryMilliVolts / 1000.0f; snprintf(battStr, sizeof(battStr), "Batt %d%% %.1fv", batteryPercentage, volts); uint16_t textWidth = display.getTextWidth(battStr); int textX = display.width() - textWidth - 2; if (outIconX) *outIconX = textX; display.setCursor(textX, 0); display.print(battStr); display.setTextSize(1); // restore default text size #elif defined(LILYGO_TECHO_LITE) // T-Echo Lite: text-only battery (icon misaligns due to fillRect/setCursor offset mismatch at 2× scale) char battStr[8]; snprintf(battStr, sizeof(battStr), "%d%%", batteryPercentage); uint16_t textWidth = display.getTextWidth(battStr); int textX = display.width() - textWidth - 2; if (outIconX) *outIconX = textX; display.setCursor(textX, 0); // Same baseline as node name (HOME_HDR_Y) display.print(battStr); display.setTextSize(1); #else // 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 #endif } #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 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), sensors_lpp(200) { } bool isEditingUTC() const { return _editing_utc; } bool isOnRecentPage() const { return _page == HomePage::RECENT; } bool isOnShutdownPage() const { return _page == HomePage::SHUTDOWN; } void cancelEditing() { if (_editing_utc) { _node_prefs->utc_offset_hours = _saved_utc_offset; _editing_utc = false; } } 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_T5S3_EPaper_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) { #if defined(LilyGo_T5S3_EPaper_Pro) board.setBacklight(false); #endif 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)); #if defined(LilyGo_T5S3_EPaper_Pro) // T5S3: FreeSans12pt ascenders need more room than built-in font. // Shift header elements down by 4 virtual units (~17px physical). #define HOME_HDR_Y 1 #elif defined(LILYGO_TECHO_LITE) #define HOME_HDR_Y 0 #else #define HOME_HDR_Y -3 #endif 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_TECHO_LITE) int y = 13; // Below header #elif defined(LilyGo_T5S3_EPaper_Pro) int y = 14; // Closer to header #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_T5S3_EPaper_Pro) _task->setHomeShowingTiles(true); #endif #if defined(LilyGo_T5S3_EPaper_Pro) #if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION) int y = 18; // Tighter spacing — connectivity info fills gap below dots #else int y = 26; // Standalone: extra line below dots (no IP/Connected row) #endif #elif defined(LILYGO_TECHO_LITE) int y = 18; // Below page dots #else int y = 20; #endif display.setColor(DisplayDriver::YELLOW); display.setTextSize(2); sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount()); display.drawTextCentered(display.width() / 2, y, tmp); #if defined(LILYGO_TECHO_LITE) y += 12; // Compact #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_TECHO_LITE) y += 14; // Compact #else y += 18; #endif #endif } #endif // ----- T5S3: Tappable tile grid (touch-friendly home screen) ----- #if defined(LilyGo_T5S3_EPaper_Pro) // 3×2 grid of tiles below MSG count // Virtual coords (128×128), scaled by DisplayDriver { struct Tile { const uint8_t* icon; const char* label; }; const Tile tiles[2][3] = { { {icon_envelope, "Messages"}, {icon_people, "Contacts"}, {icon_gear, "Settings"} }, #ifdef MECK_WEB_READER { {icon_book, "Reader"}, {icon_notepad, "Notes"}, {icon_search, "Browser"} } #else { {icon_book, "Reader"}, {icon_notepad, "Notes"}, {icon_search, "Discover"} } #endif }; const int tileW = 40; const int tileH = 22; const int gapX = 1; const int gapY = 1; const int gridW = tileW * 3 + gapX * 2; const int gridX = (display.width() - gridW) / 2; const int gridY = y + 2; _task->setTileGridVY(gridY); // Store for touch hit testing for (int row = 0; row < 2; row++) { for (int col = 0; col < 3; col++) { int tx = gridX + col * (tileW + gapX); int ty = gridY + row * (tileH + gapY); // Tile border display.setColor(DisplayDriver::LIGHT); display.drawRect(tx, ty, tileW, tileH); // Icon centered in tile int iconX = tx + (tileW - HOME_ICON_W) / 2; int iconY = ty + 2; display.drawXbm(iconX, iconY, tiles[row][col].icon, HOME_ICON_W, HOME_ICON_H); // Label centered below icon display.setTextSize(_node_prefs->smallTextSize()); display.drawTextCentered(tx + tileW / 2, ty + 15, tiles[row][col].label); } } // Third row: Trace (col 0) + Games (col 1) { int row3y = gridY + 2 * (tileH + gapY); // Trace tile (column 0) int col0x = gridX; display.setColor(DisplayDriver::LIGHT); display.drawRect(col0x, row3y, tileW, tileH); int iconX = col0x + (tileW - HOME_ICON_W) / 2; int iconY = row3y + 2; display.drawXbm(iconX, iconY, icon_trace, HOME_ICON_W, HOME_ICON_H); display.setTextSize(_node_prefs->smallTextSize()); display.drawTextCentered(col0x + tileW / 2, row3y + 15, "Trace"); // Games tile (column 1) int col1x = gridX + (tileW + gapX); display.setColor(DisplayDriver::LIGHT); display.drawRect(col1x, row3y, tileW, tileH); iconX = col1x + (tileW - HOME_ICON_W) / 2; iconY = row3y + 2; display.drawXbm(iconX, iconY, icon_gamepad, HOME_ICON_W, HOME_ICON_H); display.setTextSize(_node_prefs->smallTextSize()); display.drawTextCentered(col1x + tileW / 2, row3y + 15, "Games"); } // Nav hint at bottom of screen display.setColor(DisplayDriver::GREEN); display.setTextSize(_node_prefs->smallTextSize()); display.drawTextCentered(display.width() / 2, display.height() - 8, "Tap tile to open"); } display.setTextSize(1); #else // Non-T5S3: keyboard shortcut menu #if defined(LILYGO_TECHO_LITE) // T-Echo Lite: compact centered menu (tiny font fits 117px virtual width) display.setColor(DisplayDriver::LIGHT); display.setTextSize(0); // 6×8 built-in font y += 2; display.drawTextCentered(display.width() / 2, y, "M:Msgs C:Contacts"); y += 8; display.drawTextCentered(display.width() / 2, y, "S:Set F:Discover"); y += 8; display.drawTextCentered(display.width() / 2, y, "H:Last Heard"); y += 9; if (y < display.height() - 14) { display.setColor(DisplayDriver::GREEN); display.drawTextCentered(display.width() / 2, y, "Arrows: cycle views"); } display.setTextSize(1); // restore #else // ----- 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_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 y += 6; 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_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 += 14; } // 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 // LILYGO_TECHO_LITE #endif } 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; 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, y); display.print(tmp); } // Hint for full Last Heard screen display.setColor(DisplayDriver::LIGHT); display.setTextSize(_node_prefs->smallTextSize()); #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, display.height() - 24, "Tap here for full Last Heard list"); #else display.drawTextCentered(display.width() / 2, display.height() - 24, "H: Full Last Heard list"); #endif } 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); #ifdef BLE_PIN_CODE } else if (_page == HomePage::BLUETOOTH) { display.setColor(DisplayDriver::GREEN); #if defined(LilyGo_T5S3_EPaper_Pro) display.drawXbm((display.width() - 32) / 2, 28, #else display.drawXbm((display.width() - 32) / 2, 18, #endif _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 32, 32); if (_task->hasConnection()) { display.setColor(DisplayDriver::GREEN); display.setTextSize(1); #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, 64, "< Connected >"); #else display.drawTextCentered(display.width() / 2, 53, "< Connected >"); #endif } else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) { display.setColor(DisplayDriver::RED); display.setTextSize(2); sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, 64, tmp); #else display.drawTextCentered(display.width() / 2, 53, tmp); #endif } display.setColor(DisplayDriver::GREEN); display.setTextSize(1); #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, 80, "toggle: " PRESS_LABEL); #else display.drawTextCentered(display.width() / 2, 68, "toggle: " PRESS_LABEL); display.drawTextCentered(display.width() / 2, 78, "or press Enter key"); #endif #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); #if defined(LilyGo_T5S3_EPaper_Pro) display.drawXbm((display.width() - 32) / 2, 28, advert_icon, 32, 32); #else display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); #endif #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, 64, "advert: " PRESS_LABEL); #else display.drawTextCentered(display.width() / 2, 57, "advert: " PRESS_LABEL); display.drawTextCentered(display.width() / 2, 67, "or press Enter key"); #endif #if ENV_INCLUDE_GPS == 1 } 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, y, buf); y = y + 12; display.drawTextLeftAlign(0, y, "sat"); sprintf(buf, "%d", nmea->satellitesCount()); display.drawTextRightAlign(display.width()-1, 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, 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, y, buf); y = y + 12; display.drawTextLeftAlign(0, y, "alt"); sprintf(buf, "%.2f", nmea->getAltitude()/1000.); display.drawTextRightAlign(display.width()-1, 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, y, buf); } else { display.drawTextLeftAlign(0, y, "time(U)"); display.drawTextRightAlign(display.width()-1, 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, 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, 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, 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, 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, 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, y, buf); #endif } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::GREEN); display.setTextSize(1); if (_shutdown_init) { #if defined(LilyGo_T5S3_EPaper_Pro) board.setBacklight(false); #endif display.drawTextCentered(display.width() / 2, 34, "hibernating..."); } else if (_poweroff_confirm) { // Confirmation prompt for power off #if defined(LilyGo_T5S3_EPaper_Pro) display.drawXbm((display.width() - 32) / 2, 28, power_icon, 32, 32); #else display.drawXbm((display.width() - 32) / 2, 20, power_icon, 32, 32); #endif #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, 64, "power off device?"); display.drawTextCentered(display.width() / 2, 76, "usb-c to wake"); #else 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"); #endif } else { // Menu: hibernate / power off #if defined(LilyGo_T5S3_EPaper_Pro) display.drawXbm((display.width() - 32) / 2, 20, power_icon, 32, 32); const int y1 = 58, y2 = 70; #else display.drawXbm((display.width() - 32) / 2, 20, power_icon, 32, 32); const int y1 = 56, y2 = 68; #endif 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); } } return _editing_utc ? 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 } // 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; } #ifdef 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 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 — T5S3 and T-Deck Pro // Big clock, battery %, unread message count. // T5S3: Long press boot button to lock/unlock. Touch disabled while locked. // T-Deck Pro: Double-press boot button to lock/unlock. Touch+keyboard disabled. // ========================================================================== #if defined(LilyGo_T5S3_EPaper_Pro) || 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 ---- #if defined(LilyGo_T5S3_EPaper_Pro) display.setTextSize(5); // T5S3: FreeSansBold24pt × 5 #else display.setTextSize(5); // T-Deck Pro: FreeSansBold12pt at GxEPD 2× scale #endif 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); } // ---- Unlock hint ---- #if defined(LilyGo_T5S3_EPaper_Pro) display.setTextSize(_node_prefs->smallTextSize()); display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 120, "Hold button to unlock"); #endif 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_T5S3_EPaper_Pro) || 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); #if !defined(LILYGO_TECHO_LITE) && !defined(LILYGO_TECHO_CARD) text_reader = new TextReaderScreen(this, node_prefs); notes_screen = new NotesScreen(this, node_prefs); #else text_reader = nullptr; // T-Echo Lite: excluded to save RAM (256KB nRF52) notes_screen = nullptr; #endif 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); 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_T5S3_EPaper_Pro) || 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 && !defined(LILYGO_TECHO_CARD) map_screen = new MapScreen(this); #else map_screen = nullptr; #endif #if defined(LilyGo_T5S3_EPaper_Pro) // Apply saved display preferences before first render if (_node_prefs->portrait_mode) { ::display.setPortraitMode(true); } #endif // Apply saved dark mode preference (both T-Deck Pro and T5S3) #if defined(LilyGo_T5S3_EPaper_Pro) || 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) { _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. #ifdef MECK_AUDIO_VARIANT if (!suppressNotif) { const char* customSound = notifSounds.getSoundForChannel(channel_idx); 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); } // 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 (!isOnRepeaterAdmin() && !isRoomMsg && !suppressNotif) { 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) { digitalWrite(KB_BL_PIN, HIGH); _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 } void UITask::setCurrScreen(UIScreen* c) { 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 } } #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). #ifdef 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 { #ifdef 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_T5S3_EPaper_Pro) // T5S3: single click = cycle pages on home, go back to home from elsewhere // Ignored while locked — long press required to unlock if (_locked) { c = 0; } else if (_vkbActive) { onVKBCancel(); c = 0; } else if (curr == home) { c = checkDisplayOn(KEY_NEXT); } else { // Navigate back: reader reading→file list, file list→home, others→home #if !defined(LILYGO_TECHO_LITE) && !defined(LILYGO_TECHO_CARD) if (isOnTextReader()) { TextReaderScreen* reader = (TextReaderScreen*)text_reader; if (reader && reader->isReading()) { c = checkDisplayOn('q'); // reading mode: close book → file list } else { gotoHomeScreen(); // file list: go home c = 0; } } else if (isOnNotesScreen()) { NotesScreen* notes = (NotesScreen*)notes_screen; if (notes && notes->isEditing()) { notes->triggerSaveAndExit(); // save and return to file list } else { notes->exitNotes(); gotoHomeScreen(); } c = 0; } else #endif if (isOnChannelPickerScreen()) { gotoHomeScreen(); // picker → home c = 0; } else if (isOnChannelScreen()) { gotoChannelPickerScreen(); // channel messages → picker c = 0; } else { gotoHomeScreen(); c = 0; // consumed } } #elif 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 (boot button on T5S3) 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_T5S3_EPaper_Pro) || 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) { digitalWrite(KB_BL_PIN, LOW); } #else digitalWrite(KB_BL_PIN, LOW); #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) { digitalWrite(KB_BL_PIN, LOW); } _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; digitalWrite(KB_BL_PIN, _callFlashState ? HIGH : LOW); // 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 (_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_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) if (_node_prefs && display.isDarkMode() != (_node_prefs->dark_mode != 0)) { display.setDarkMode(_node_prefs->dark_mode != 0); } #endif #if defined(LilyGo_T5S3_EPaper_Pro) // Sync portrait mode with prefs (T5S3 only) if (_node_prefs && display.isPortraitMode() != (_node_prefs->portrait_mode != 0)) { display.setPortraitMode(_node_prefs->portrait_mode != 0); // Text reader layout depends on orientation -- force recalculation if (text_reader) { ((TextReaderScreen*)text_reader)->invalidateLayout(); } } #endif // Sync font style with prefs (settings toggle takes effect here) #if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) if (_node_prefs && display.getFontStyle() != _node_prefs->ui_font_style) { display.setFontStyle(_node_prefs->ui_font_style); } #endif _display->startFrame(); #if defined(LilyGo_T5S3_EPaper_Pro) if (_vkbActive) { display.setForcePartial(true); // No flash while typing _vkb.render(*_display); _next_refresh = millis() + 500; // Moderate refresh for cursor blink // Check if keyboard was submitted or cancelled during render cycle if (_vkb.status() == VKB_SUBMITTED) { onVKBSubmit(); } else if (_vkb.status() == VKB_CANCELLED) { onVKBCancel(); } } else { // Default: allow full refresh. Override for notes editing (no flash while typing). display.setForcePartial(false); if (isOnNotesScreen() && ((NotesScreen*)notes_screen)->isEditing()) { display.setForcePartial(true); } int delay_millis = curr->render(*_display); // Check if settings screen needs VKB for WiFi password entry #ifdef MECK_WIFI_COMPANION if (isOnSettingsScreen() && !_vkbActive) { SettingsScreen* ss = (SettingsScreen*)settings_screen; if (ss->needsWifiVKB()) { ss->clearWifiNeedsVKB(); showVirtualKeyboard(VKB_WIFI_PASSWORD, "WiFi Password", "", 63); } } #endif // Check if settings screen needs VKB for text editing (channel name, freq, APN) if (isOnSettingsScreen() && !_vkbActive) { SettingsScreen* ss = (SettingsScreen*)settings_screen; if (ss->needsTextVKB()) { ss->clearTextNeedsVKB(); // Pick a context-appropriate label const char* label = "Edit"; SettingsRowType rt = ss->getCurrentRowType(); if (rt == ROW_NAME) label = "Node Name"; else if (rt == ROW_ADD_CHANNEL) label = "Channel Name"; else if (rt == ROW_FREQ) label = "Frequency"; showVirtualKeyboard(VKB_SETTINGS_TEXT, label, ss->getEditBuf(), 31); } } 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; #if defined(LilyGo_T5S3_EPaper_Pro) _display->drawTextCentered(cx, startY, "Swipe: Navigate"); _display->drawTextCentered(cx, startY + lineH, "Tap: Select"); _display->drawTextCentered(cx, startY + lineH * 2, "Long Press: Action"); _display->drawTextCentered(cx, startY + lineH * 3, "Boot Btn: Home"); _display->drawTextCentered(cx, startY + lineH * 4 + 4, "[Tap to dismiss hint]"); #else _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 hint]"); #endif _next_refresh = _hintExpiry; } else if (_hintActive) { // Hint expired — auto-dismiss dismissBootHint(); _next_refresh = millis() + 200; } else if (millis() < _alert_expiry) { _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); _display->drawRect(p, y, _display->width() - p*2, y); _display->drawTextCentered(_display->width() / 2, y + p*3, _alert); _next_refresh = _alert_expiry; } else { _next_refresh = millis() + delay_millis; } } #else 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; } #endif _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 (_next_refresh < minNext) _next_refresh = minNext; } // 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_T5S3_EPaper_Pro) || 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. T5S3: every 2 minutes. // Wakes the display driver briefly to render, then auto-off handles it. // T5S3 standalone: no refreshes once powersaving begins — the device // shows "hibernating..." and enters light sleep instead. #if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION) // T5S3 standalone: only refresh while still active (before powersaving kicks in) if (_locked && _display != NULL && _display->isOn()) { const unsigned long LOCK_REFRESH_INTERVAL = 2UL * 60UL * 1000UL; // 2 minutes #elif defined(LilyGo_T5S3_EPaper_Pro) // T5S3 BLE/WiFi: refresh every 2 minutes if (_locked && _display != NULL) { const unsigned long LOCK_REFRESH_INTERVAL = 2UL * 60UL * 1000UL; // 2 minutes #elif 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 // ── T5S3 standalone powersaving ────────────────────────────────────────── // When locked with display off, enter ESP32 light sleep (~8 mA total). // Radio stays in continuous RX — DIO1 going HIGH wakes the CPU instantly. // Boot button (GPIO0 LOW) and a 30-min safety timer also wake. // First sleep starts 60s after lock; subsequent cycles wake for 5s to let // the mesh stack process/relay any received packet, then sleep again. #if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION) if (_locked && _display != NULL && !_display->isOn()) { unsigned long now = millis(); if (now - _psLastActive >= _psNextSleepSecs * 1000UL) { // First sleep entry: render a static "hibernating..." frame on the // e-ink. Since e-ink retains its image indefinitely without power, // this tells the user the device is in low-power mode until they // wake it with the boot button. if (_psNextSleepSecs == 60) { _display->turnOn(); _display->startFrame(); _display->setTextSize(1); _display->setColor(DisplayDriver::GREEN); _display->drawTextCentered(_display->width() / 2, 34, "hibernating..."); _display->endFrame(); delay(700); // Allow e-ink refresh to complete _display->turnOff(); } Serial.println("[POWERSAVE] Entering light sleep (locked+idle)"); board.sleep(1800); // Light sleep up to 30 min // ── CPU resumes here on wake ── unsigned long wakeAt = millis(); _psLastActive = wakeAt; _psNextSleepSecs = 5; // Stay awake 5s for mesh processing esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); if (cause == ESP_SLEEP_WAKEUP_GPIO) { // Boot button pressed — unlock and return to normal use Serial.println("[POWERSAVE] Woke by button — unlocking"); unlockScreen(); _psNextSleepSecs = 60; // Reset to long delay after user interaction } else if (cause == ESP_SLEEP_WAKEUP_EXT1) { Serial.println("[POWERSAVE] Woke by LoRa packet"); } else if (cause == ESP_SLEEP_WAKEUP_TIMER) { Serial.println("[POWERSAVE] Woke by timer"); } } } else if (!_locked) { // Not locked — keep powersaving timer reset so first sleep is 60s after lock _psLastActive = millis(); _psNextSleepSecs = 60; } #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(THINKNODE_M1) || defined(LILYGO_TECHO) || 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 shutdown(); } } else { _low_batt_count = 0; } next_batt_chck = millis() + 8000; } #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_T5S3_EPaper_Pro) || 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 } #if defined(LilyGo_T5S3_EPaper_Pro) else if (_vkbActive) { onVKBCancel(); // Long press while VKB → cancel c = 0; } else if (_locked) { unlockScreen(); c = 0; } else { lockScreen(); c = 0; } #endif return c; } char UITask::handleDoubleClick(char c) { MESH_DEBUG_PRINTLN("UITask: double click triggered"); #if defined(LilyGo_T5S3_EPaper_Pro) // Double-click boot button → full brightness backlight toggle if (board.isBacklightOn()) { board.setBacklight(false); } else { board.setBacklightBrightness(153); board.setBacklight(true); } c = 0; // consume event — don't pass through as navigation #elif 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); #if defined(LilyGo_T5S3_EPaper_Pro) // Triple-click → half brightness backlight (comfortable reading) if (board.isBacklightOn()) { board.setBacklight(false); // If already on, turn off } else { board.setBacklightBrightness(4); board.setBacklight(true); } #else toggleBuzzer(); #endif c = 0; return c; } #if defined(LilyGo_T5S3_EPaper_Pro) || 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_T5S3_EPaper_Pro) board.setBacklight(false); // Save power (T5S3 backlight) #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 #if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION) _psLastActive = millis(); // Start powersaving countdown (60s to first sleep) _psNextSleepSecs = 60; #endif 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_T5S3_EPaper_Pro || LilyGo_TDeck_Pro #if defined(LilyGo_T5S3_EPaper_Pro) void UITask::showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx) { _vkb.open(purpose, label, initial, maxLen, contextIdx); _vkbActive = true; _vkbOpenedAt = millis(); _screenBeforeVKB = curr; _next_refresh = 0; _auto_off = millis() + 120000; // 2min timeout while typing Serial.printf("[UI] VKB opened: %s\n", label); } void UITask::onVKBSubmit() { _vkbActive = false; const char* text = _vkb.getText(); VKBPurpose purpose = _vkb.purpose(); int idx = _vkb.contextIdx(); Serial.printf("[UI] VKB submit: purpose=%d idx=%d text='%s'\n", purpose, idx, text); switch (purpose) { case VKB_CHANNEL_MSG: { if (strlen(text) == 0) break; ChannelDetails channel; if (the_mesh.getChannel(idx, channel)) { uint32_t timestamp = rtc_clock.getCurrentTime(); int textLen = strlen(text); if (the_mesh.sendGroupMessage(timestamp, channel.channel, the_mesh.getNodePrefs()->node_name, text, textLen)) { addSentChannelMessage(idx, the_mesh.getNodePrefs()->node_name, text); the_mesh.queueSentChannelMessage(idx, timestamp, the_mesh.getNodePrefs()->node_name, text); showAlert("Sent!", 1500); } else { showAlert("Send failed!", 1500); } } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } case VKB_DM: { if (strlen(text) == 0) break; bool dmSuccess = false; if (the_mesh.uiSendDirectMessage((uint32_t)idx, text)) { // Add to channel screen so sent DM appears in conversation view ContactInfo dmRecipient; if (the_mesh.getContactByIdx(idx, dmRecipient)) { addSentDM(dmRecipient.name, the_mesh.getNodePrefs()->node_name, text); } dmSuccess = true; } // Return to DM conversation if we have contact info ContactInfo dmContact; if (the_mesh.getContactByIdx(idx, dmContact)) { ChannelScreen* cs = (ChannelScreen*)channel_screen; uint8_t savedPerms = (cs && cs->isDMConversation()) ? cs->getDMContactPerms() : 0; gotoDMConversation(dmContact.name, idx, savedPerms); } else if (_screenBeforeVKB) { setCurrScreen(_screenBeforeVKB); } // Show alert AFTER navigation (setCurrScreen clears prior alerts) showAlert(dmSuccess ? "DM sent!" : "DM failed!", 1500); break; } case VKB_ADMIN_PASSWORD: { // Feed each character to the admin screen, then Enter RepeaterAdminScreen* admin = (RepeaterAdminScreen*)getRepeaterAdminScreen(); if (admin) { for (int i = 0; text[i]; i++) { admin->handleInput(text[i]); } admin->handleInput('\r'); } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } case VKB_ADMIN_CLI: { RepeaterAdminScreen* admin = (RepeaterAdminScreen*)getRepeaterAdminScreen(); if (admin) { for (int i = 0; text[i]; i++) { admin->handleInput(text[i]); } admin->handleInput('\r'); } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } case VKB_SETTINGS_NAME: { if (strlen(text) > 0) { strncpy(_node_prefs->node_name, text, sizeof(_node_prefs->node_name) - 1); _node_prefs->node_name[sizeof(_node_prefs->node_name) - 1] = '\0'; the_mesh.savePrefs(); showAlert("Name saved", 1000); } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } case VKB_SETTINGS_TEXT: { // Generic settings text edit — copy text back to settings edit buffer // and confirm via the normal Enter path (handles name/freq/channel/APN) SettingsScreen* ss = (SettingsScreen*)settings_screen; if (strlen(text) > 0) { ss->submitEditText(text); } else { // Empty submission — cancel the edit ss->handleInput('q'); } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } case VKB_NOTES: { #if !defined(LILYGO_TECHO_LITE) && !defined(LILYGO_TECHO_CARD) NotesScreen* notes = (NotesScreen*)getNotesScreen(); if (notes && strlen(text) > 0) { for (int i = 0; text[i]; i++) { notes->handleInput(text[i]); } } #endif if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } #ifdef MECK_WIFI_COMPANION case VKB_WIFI_PASSWORD: { SettingsScreen* ss = (SettingsScreen*)settings_screen; ss->submitWifiPassword(text); if (WiFi.status() == WL_CONNECTED) { showAlert("WiFi connected!", 2000); } else { showAlert("WiFi failed", 2000); } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } #endif #ifdef MECK_WEB_READER case VKB_WEB_URL: { WebReaderScreen* wr = (WebReaderScreen*)getWebReaderScreen(); if (wr && strlen(text) > 0) { wr->setUrlText(text); // Copy text + set _urlEditing = true wr->handleInput('\r'); // Triggers auto-prefix + fetch } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } case VKB_WEB_SEARCH: { WebReaderScreen* wr = (WebReaderScreen*)getWebReaderScreen(); if (wr && strlen(text) > 0) { wr->setSearchText(text); // Copy text + set _searchEditing = true wr->handleInput('\r'); // Triggers DDG search URL build + fetch } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } case VKB_WEB_WIFI_PASS: { WebReaderScreen* wr = (WebReaderScreen*)getWebReaderScreen(); if (wr && strlen(text) > 0) { wr->setWifiPassText(text); // Copy password text wr->handleInput('\r'); // Triggers WiFi connect } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } case VKB_WEB_LINK: { WebReaderScreen* wr = (WebReaderScreen*)getWebReaderScreen(); if (wr && strlen(text) > 0) { // Activate link input mode, feed digits, then submit wr->handleInput('l'); // Enter link selection mode for (int i = 0; text[i]; i++) { if (text[i] >= '0' && text[i] <= '9') { wr->handleInput(text[i]); } } wr->handleInput('\r'); // Confirm link number → navigate } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } #endif case VKB_TEXT_PAGE: { #if !defined(LILYGO_TECHO_LITE) && !defined(LILYGO_TECHO_CARD) if (strlen(text) > 0) { int pageNum = atoi(text); TextReaderScreen* reader = (TextReaderScreen*)getTextReaderScreen(); if (reader && pageNum > 0) { reader->gotoPage(pageNum); } } #endif if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } case VKB_TRACE_PATH: { TraceScreen* ts = (TraceScreen*)getTraceScreen(); if (ts) { ts->setTypedPath(text); } if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); break; } } _screenBeforeVKB = nullptr; _next_refresh = 0; display.setForcePartial(false); // Next frame does full refresh to clear VKB ghosts display.invalidateFrameCRC(); } void UITask::onVKBCancel() { _vkbActive = false; if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB); _screenBeforeVKB = nullptr; _next_refresh = 0; display.setForcePartial(false); // Next frame does full refresh to clear VKB ghosts display.invalidateFrameCRC(); Serial.println("[UI] VKB cancelled"); } #ifdef MECK_CARDKB void UITask::feedCardKBChar(char c) { if (_vkbActive) { // VKB is open — feed character into its text buffer if (_vkb.feedChar(c)) { _next_refresh = 0; // Redraw VKB immediately _auto_off = millis() + 120000; // Extend timeout while typing // Check if feedChar triggered submit or cancel if (_vkb.status() == VKB_SUBMITTED) { onVKBSubmit(); } else if (_vkb.status() == VKB_CANCELLED) { onVKBCancel(); } } else { // feedChar returned false — nav keys (arrows) while VKB is active // Not consumed; could be used for cursor movement in future } } else { // No VKB active — route as normal navigation key injectKey(c); } } #endif #endif 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 notify(UIEventType::ack); } else { // 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 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_T5S3_EPaper_Pro) || 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(); } bool UITask::isHomeOnRecentPage() const { return curr == home && ((HomeScreen *) home)->isOnRecentPage(); } bool UITask::isHomeOnShutdownPage() const { return curr == home && ((HomeScreen *) home)->isOnShutdownPage(); } 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 #if !defined(LILYGO_TECHO_LITE) && !defined(LILYGO_TECHO_CARD) 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; #endif } void UITask::gotoNotesScreen() { if (!notes_screen) return; // Not available on this platform #if !defined(LILYGO_TECHO_LITE) && !defined(LILYGO_TECHO_CARD) 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; #endif } 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; 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); // Add to channel history with path_len=0 (local message) ((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg); } void UITask::addSentDM(const char* recipientName, const char* sender, const char* text) { // 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); } 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::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; } 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 (T-Echo Card) #if !defined(LILYGO_TECHO_CARD) 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 } #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