mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
2702 lines
88 KiB
C++
2702 lines
88 KiB
C++
#include "UITask.h"
|
||
#include <helpers/TxtDataHelpers.h>
|
||
#include "../MyMesh.h"
|
||
#include "NotesScreen.h"
|
||
#include "RepeaterAdminScreen.h"
|
||
#include "DiscoveryScreen.h"
|
||
#include "LastHeardScreen.h"
|
||
#ifdef MECK_WEB_READER
|
||
#include "WebReaderScreen.h"
|
||
#endif
|
||
#if HAS_GPS
|
||
#include "MapScreen.h"
|
||
#endif
|
||
#include "target.h"
|
||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||
#include "HomeIcons.h"
|
||
#endif
|
||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||
#include <WiFi.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
|
||
|
||
#if UI_HAS_JOYSTICK
|
||
#define PRESS_LABEL "press Enter"
|
||
#elif defined(LilyGo_T5S3_EPaper_Pro)
|
||
#define PRESS_LABEL "long press"
|
||
#else
|
||
#define PRESS_LABEL "long press"
|
||
#endif
|
||
|
||
#include "icons.h"
|
||
#include "ChannelScreen.h"
|
||
#include "ContactsScreen.h"
|
||
#include "TextReaderScreen.h"
|
||
#include "SettingsScreen.h"
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
#include "AudiobookPlayerScreen.h"
|
||
#endif
|
||
#ifdef HAS_4G_MODEM
|
||
#include "SMSScreen.h"
|
||
#include "ModemManager.h"
|
||
#endif
|
||
|
||
class SplashScreen : public UIScreen {
|
||
UITask* _task;
|
||
unsigned long dismiss_after;
|
||
char _version_info[24];
|
||
|
||
public:
|
||
SplashScreen(UITask* task) : _task(task) {
|
||
// strip off dash and commit hash by changing dash to null terminator
|
||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||
const char *ver = FIRMWARE_VERSION;
|
||
const char *dash = strchr(ver, '-');
|
||
|
||
int len = dash ? dash - ver : strlen(ver);
|
||
if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1;
|
||
memcpy(_version_info, ver, len);
|
||
_version_info[len] = 0;
|
||
|
||
dismiss_after = millis() + BOOT_SCREEN_MILLIS;
|
||
}
|
||
|
||
int render(DisplayDriver& display) override {
|
||
// meshcore logo
|
||
display.setColor(DisplayDriver::BLUE);
|
||
int logoWidth = 128;
|
||
display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||
|
||
// version info
|
||
display.setColor(DisplayDriver::LIGHT);
|
||
display.setTextSize(2);
|
||
display.drawTextCentered(display.width()/2, 22, _version_info);
|
||
|
||
display.setTextSize(1);
|
||
display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE);
|
||
|
||
return 1000;
|
||
}
|
||
|
||
void poll() override {
|
||
if (millis() >= dismiss_after) {
|
||
Serial.println(">>> SplashScreen calling gotoHomeScreen() <<<");
|
||
_task->gotoHomeScreen();
|
||
}
|
||
}
|
||
};
|
||
|
||
class HomeScreen : public UIScreen {
|
||
enum HomePage {
|
||
FIRST,
|
||
RECENT,
|
||
RADIO,
|
||
#ifdef BLE_PIN_CODE
|
||
BLUETOOTH,
|
||
#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 _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 voltage-based estimation to match BLE app readings
|
||
uint8_t batteryPercentage = 0;
|
||
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;
|
||
}
|
||
|
||
display.setColor(DisplayDriver::GREEN);
|
||
display.setTextSize(0);
|
||
|
||
#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
|
||
#else
|
||
// T-Deck Pro: icon + percentage text
|
||
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);
|
||
|
||
// 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(0); // 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
|
||
}
|
||
#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), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||
|
||
bool isEditingUTC() const { return _editing_utc; }
|
||
bool isOnRecentPage() const { return _page == HomePage::RECENT; }
|
||
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()) {
|
||
_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
|
||
// node name (tinyfont to avoid overlapping clock)
|
||
display.setTextSize(0);
|
||
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
|
||
#else
|
||
#define HOME_HDR_Y -3
|
||
#endif
|
||
display.setCursor(0, HOME_HDR_Y);
|
||
display.print(filtered_name);
|
||
|
||
// battery voltage
|
||
#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);
|
||
#else
|
||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||
#endif
|
||
|
||
// centered clock (tinyfont) - 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(0); // tinyfont
|
||
display.setColor(DisplayDriver::LIGHT);
|
||
uint16_t tw = display.getTextWidth(timeBuf);
|
||
int clockX = (display.width() - tw) / 2;
|
||
display.setCursor(clockX, HOME_HDR_Y); // align with node name Y
|
||
display.print(timeBuf);
|
||
display.setTextSize(1); // restore
|
||
}
|
||
}
|
||
// curr page indicator
|
||
#if 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
|
||
#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);
|
||
y += 14; // Reduced from 18
|
||
|
||
#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(0); // Tiny font for IP
|
||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||
y += 8;
|
||
}
|
||
#endif
|
||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||
if (_task->hasConnection()) {
|
||
display.setColor(DisplayDriver::GREEN);
|
||
display.setTextSize(0); // Tiny font for Connected
|
||
display.drawTextCentered(display.width() / 2, y, "< Connected >");
|
||
y += 8; // Reduced from 12
|
||
#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);
|
||
y += 18;
|
||
#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 = 28;
|
||
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 + 4;
|
||
display.drawXbm(iconX, iconY, tiles[row][col].icon, HOME_ICON_W, HOME_ICON_H);
|
||
|
||
// Label centered below icon
|
||
display.setTextSize(0);
|
||
display.drawTextCentered(tx + tileW / 2, ty + 18, tiles[row][col].label);
|
||
}
|
||
}
|
||
|
||
// Nav hint below grid
|
||
y = gridY + 2 * tileH + gapY + 2;
|
||
display.setColor(DisplayDriver::GREEN);
|
||
display.setTextSize(0);
|
||
display.drawTextCentered(display.width() / 2, y, "Tap tile to open");
|
||
}
|
||
display.setTextSize(1);
|
||
|
||
#else
|
||
// ----- T-Deck Pro: Keyboard shortcut text menu -----
|
||
// Menu shortcuts - tinyfont monospaced grid
|
||
y += 6;
|
||
display.setColor(DisplayDriver::LIGHT);
|
||
display.setTextSize(0); // tinyfont 6x8 monospaced
|
||
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) && defined(MECK_WEB_READER)
|
||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
|
||
#elif defined(MECK_AUDIO_VARIANT)
|
||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [F] Discover ");
|
||
#elif defined(MECK_WEB_READER)
|
||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||
#else
|
||
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
|
||
#endif
|
||
y += 14;
|
||
|
||
// Nav hint
|
||
display.setColor(DisplayDriver::GREEN);
|
||
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
|
||
display.setTextSize(1); // restore
|
||
#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(0);
|
||
#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, 72, "toggle: " PRESS_LABEL);
|
||
#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(0);
|
||
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 += 10;
|
||
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 += 10;
|
||
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
|
||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||
wy += 12;
|
||
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 += 12;
|
||
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, 64 - 11, "advert: " PRESS_LABEL);
|
||
#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(0);
|
||
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); // Turn off backlight on hibernate
|
||
#endif
|
||
display.drawTextCentered(display.width() / 2, 34, "hibernating...");
|
||
} else {
|
||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||
display.drawXbm((display.width() - 32) / 2, 28, power_icon, 32, 32);
|
||
#else
|
||
display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32);
|
||
#endif
|
||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||
display.drawTextCentered(display.width() / 2, 64, "hibernate:" PRESS_LABEL);
|
||
#else
|
||
display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL);
|
||
#endif
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
if (c == KEY_LEFT || c == KEY_PREV) {
|
||
_page = (_page + HomePage::Count - 1) % HomePage::Count;
|
||
return true;
|
||
}
|
||
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
||
_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
|
||
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
|
||
_shutdown_init = true;
|
||
_shutdown_at = millis() + 900; // allow e-ink refresh (644ms) before shutdown
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
};
|
||
|
||
class MsgPreviewScreen : public UIScreen {
|
||
UITask* _task;
|
||
mesh::RTCClock* _rtc;
|
||
|
||
struct MsgEntry {
|
||
uint32_t timestamp;
|
||
char origin[62];
|
||
char msg[78];
|
||
};
|
||
#define MAX_UNREAD_MSGS 32
|
||
int num_unread;
|
||
MsgEntry unread[MAX_UNREAD_MSGS];
|
||
|
||
public:
|
||
MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; }
|
||
|
||
void addPreview(uint8_t path_len, const char* from_name, const char* msg) {
|
||
if (num_unread >= MAX_UNREAD_MSGS) return; // full
|
||
|
||
auto p = &unread[num_unread++];
|
||
p->timestamp = _rtc->getCurrentTime();
|
||
if (path_len == 0xFF) {
|
||
sprintf(p->origin, "(D) %s:", from_name);
|
||
} else {
|
||
sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name);
|
||
}
|
||
StrHelper::strncpy(p->msg, msg, sizeof(p->msg));
|
||
}
|
||
|
||
int render(DisplayDriver& display) override {
|
||
char tmp[16];
|
||
display.setCursor(0, 0);
|
||
display.setTextSize(1);
|
||
display.setColor(DisplayDriver::GREEN);
|
||
sprintf(tmp, "Unread: %d", num_unread);
|
||
display.print(tmp);
|
||
|
||
auto p = &unread[0];
|
||
|
||
int secs = _rtc->getCurrentTime() - p->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));
|
||
}
|
||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||
display.print(tmp);
|
||
|
||
display.drawRect(0, 11, display.width(), 1); // horiz line
|
||
|
||
display.setCursor(0, 14);
|
||
display.setColor(DisplayDriver::YELLOW);
|
||
char filtered_origin[sizeof(p->origin)];
|
||
display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin));
|
||
display.print(filtered_origin);
|
||
|
||
display.setCursor(0, 25);
|
||
display.setColor(DisplayDriver::LIGHT);
|
||
char filtered_msg[sizeof(p->msg)];
|
||
display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg));
|
||
display.printWordWrap(filtered_msg, display.width());
|
||
|
||
#if AUTO_OFF_MILLIS==0 // probably e-ink
|
||
return 10000; // 10 s
|
||
#else
|
||
return 1000; // next render after 1000 ms
|
||
#endif
|
||
}
|
||
|
||
bool handleInput(char c) override {
|
||
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
||
num_unread--;
|
||
if (num_unread == 0) {
|
||
_task->gotoHomeScreen();
|
||
} else {
|
||
// delete first/curr item from unread queue
|
||
for (int i = 0; i < num_unread; i++) {
|
||
unread[i] = unread[i + 1];
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
if (c == KEY_ENTER) {
|
||
num_unread = 0; // clear unread queue
|
||
_task->gotoHomeScreen();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// ==========================================================================
|
||
// 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);
|
||
{
|
||
uint16_t mv = _task->getBattMilliVolts();
|
||
int pct = 0;
|
||
if (mv > 0) {
|
||
pct = ((mv - 3000) * 100) / (4200 - 3000);
|
||
if (pct < 0) pct = 0;
|
||
if (pct > 100) pct = 100;
|
||
}
|
||
|
||
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 ----
|
||
display.setTextSize(0);
|
||
display.setColor(DisplayDriver::LIGHT);
|
||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||
display.drawTextCentered(display.width() / 2, 120, "Hold button to unlock");
|
||
#else
|
||
display.drawTextCentered(display.width() / 2, 120, "Dbl-press 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);
|
||
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
|
||
channel_screen = new ChannelScreen(this, &rtc_clock);
|
||
((ChannelScreen*)channel_screen)->setDMUnreadPtr(_dmUnread);
|
||
contacts_screen = new ContactsScreen(this, &rtc_clock);
|
||
((ContactsScreen*)contacts_screen)->setDMUnreadPtr(_dmUnread);
|
||
text_reader = new TextReaderScreen(this);
|
||
notes_screen = new NotesScreen(this);
|
||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
|
||
last_heard_screen = new LastHeardScreen(&rtc_clock);
|
||
#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 HAS_4G_MODEM
|
||
sms_screen = new SMSScreen(this);
|
||
#endif
|
||
#if HAS_GPS
|
||
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 (_node_prefs->dark_mode) {
|
||
::display.setDarkMode(true);
|
||
}
|
||
|
||
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) {
|
||
#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;
|
||
if (msgcount == 0 && curr == msg_preview) {
|
||
gotoHomeScreen();
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
// Add to preview screen (for notifications on non-keyboard devices)
|
||
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
} 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);
|
||
}
|
||
} else {
|
||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
|
||
}
|
||
|
||
// If user is currently viewing this channel, mark it as read immediately
|
||
// (they can see the message arrive in real-time)
|
||
if (isOnChannelScreen() &&
|
||
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) {
|
||
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
|
||
}
|
||
|
||
// Per-contact DM unread tracking: find contact index by name
|
||
if (channel_idx == 0xFF && _dmUnread) {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
#if defined(LilyGo_TDeck_Pro) || defined(LilyGo_T5S3_EPaper_Pro)
|
||
// 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) {
|
||
char alertBuf[40];
|
||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||
showAlert(alertBuf, 2000);
|
||
}
|
||
#else
|
||
// Other devices: Show full preview screen (legacy behavior, skip room sync)
|
||
if (!isRoomMsg) setCurrScreen(msg_preview);
|
||
#endif
|
||
|
||
if (_display != NULL) {
|
||
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)
|
||
#ifdef KB_BL_PIN
|
||
if (_node_prefs->kb_flash_notify && !isRoomMsg) {
|
||
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();
|
||
_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 UI_HAS_JOYSTICK
|
||
int ev = user_btn.check();
|
||
if (ev == BUTTON_EVENT_CLICK) {
|
||
c = checkDisplayOn(KEY_ENTER);
|
||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||
c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code
|
||
}
|
||
ev = joystick_left.check();
|
||
if (ev == BUTTON_EVENT_CLICK) {
|
||
c = checkDisplayOn(KEY_LEFT);
|
||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||
c = handleLongPress(KEY_LEFT);
|
||
}
|
||
ev = joystick_right.check();
|
||
if (ev == BUTTON_EVENT_CLICK) {
|
||
c = checkDisplayOn(KEY_RIGHT);
|
||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||
c = handleLongPress(KEY_RIGHT);
|
||
}
|
||
ev = back_btn.check();
|
||
if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||
c = handleTripleClick(KEY_SELECT);
|
||
}
|
||
#elif 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 (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 {
|
||
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) {
|
||
// Sync dark mode with prefs (settings toggle takes effect here)
|
||
if (_node_prefs && display.isDarkMode() != (_node_prefs->dark_mode != 0)) {
|
||
display.setDarkMode(_node_prefs->dark_mode != 0);
|
||
}
|
||
#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
|
||
_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
|
||
|
||
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 800ms between renders.
|
||
// Each partial update blocks for ~644ms. Without this floor, incoming
|
||
// mesh notifications can trigger back-to-back renders that starve the
|
||
// keyboard polling loop, causing TCA8418 FIFO overflow and lost keys.
|
||
unsigned long minNext = millis() + 800;
|
||
if (_next_refresh < minNext) _next_refresh = minNext;
|
||
}
|
||
#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 — update time display every 15 minutes.
|
||
// Runs outside the _display->isOn() gate so it works even after auto-off.
|
||
// Wakes the display briefly to render, then lets auto-off turn it back off.
|
||
if (_locked && _display != NULL) {
|
||
const unsigned long LOCK_REFRESH_INTERVAL = 15UL * 60UL * 1000UL; // 15 minutes
|
||
if (millis() - _lastLockRefresh >= LOCK_REFRESH_INTERVAL) {
|
||
_lastLockRefresh = millis();
|
||
if (!_display->isOn()) {
|
||
_display->turnOn();
|
||
_auto_off = millis() + 5000; // Stay on just long enough to render + settle
|
||
}
|
||
_next_refresh = 0; // Trigger immediate render
|
||
}
|
||
}
|
||
#endif
|
||
|
||
#ifdef PIN_VIBRATION
|
||
vibration.loop();
|
||
#endif
|
||
|
||
#ifdef AUTO_SHUTDOWN_MILLIVOLTS
|
||
if (millis() > next_batt_chck) {
|
||
uint16_t milliVolts = getBattMilliVolts();
|
||
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||
_low_batt_count++;
|
||
if (_low_batt_count >= 3) { // 3 consecutive low readings (~24s) to avoid transient sags
|
||
|
||
// show low battery shutdown alert on e-ink (persists after power loss)
|
||
#if defined(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 15-min clock refresh cycle
|
||
Serial.println("[UI] Screen locked — entering low-power mode");
|
||
}
|
||
|
||
void UITask::unlockScreen() {
|
||
if (!_locked) return;
|
||
_locked = false;
|
||
if (_screenBeforeLock) {
|
||
setCurrScreen(_screenBeforeLock);
|
||
} else {
|
||
gotoHomeScreen();
|
||
}
|
||
_screenBeforeLock = nullptr;
|
||
// Ensure display is on so unlocked screen renders
|
||
if (_display != NULL && !_display->isOn()) {
|
||
_display->turnOn();
|
||
}
|
||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||
_lastInputMillis = millis(); // Reset auto-lock idle timer
|
||
_next_refresh = 0;
|
||
Serial.println("[UI] Screen unlocked — exiting low-power mode");
|
||
}
|
||
#endif // LilyGo_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_NOTES: {
|
||
NotesScreen* notes = (NotesScreen*)getNotesScreen();
|
||
if (notes && strlen(text) > 0) {
|
||
for (int i = 0; text[i]; i++) {
|
||
notes->handleInput(text[i]);
|
||
}
|
||
}
|
||
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 (strlen(text) > 0) {
|
||
int pageNum = atoi(text);
|
||
TextReaderScreen* reader = (TextReaderScreen*)getTextReaderScreen();
|
||
if (reader && pageNum > 0) {
|
||
reader->gotoPage(pageNum);
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
} else {
|
||
_next_refresh = 100; // trigger refresh
|
||
}
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
void UITask::gotoChannelScreen() {
|
||
ChannelScreen* cs = (ChannelScreen*)channel_screen;
|
||
// If currently showing DM view, reset to channel 0
|
||
if (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::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() {
|
||
TextReaderScreen* reader = (TextReaderScreen*)text_reader;
|
||
if (_display != NULL) {
|
||
reader->enter(*_display);
|
||
}
|
||
setCurrScreen(text_reader);
|
||
if (_display != NULL && !_display->isOn()) {
|
||
_display->turnOn();
|
||
}
|
||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||
_next_refresh = 100;
|
||
}
|
||
|
||
void UITask::gotoNotesScreen() {
|
||
NotesScreen* notes = (NotesScreen*)notes_screen;
|
||
if (_display != NULL) {
|
||
notes->enter(*_display);
|
||
}
|
||
// Set fresh timestamp and wire up time getter for note creation
|
||
notes->setTimestamp(rtc_clock.getCurrentTime(),
|
||
_node_prefs ? _node_prefs->utc_offset_hours : 0);
|
||
notes->setTimeGetter([]() -> uint32_t { return rtc_clock.getCurrentTime(); });
|
||
setCurrScreen(notes_screen);
|
||
if (_display != NULL && !_display->isOn()) {
|
||
_display->turnOn();
|
||
}
|
||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||
_next_refresh = 100;
|
||
}
|
||
|
||
void UITask::gotoSettingsScreen() {
|
||
((SettingsScreen *) settings_screen)->enter();
|
||
setCurrScreen(settings_screen);
|
||
if (_display != NULL && !_display->isOn()) {
|
||
_display->turnOn();
|
||
}
|
||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||
_next_refresh = 100;
|
||
}
|
||
|
||
void UITask::gotoOnboarding() {
|
||
((SettingsScreen *) settings_screen)->enterOnboarding();
|
||
setCurrScreen(settings_screen);
|
||
if (_display != NULL && !_display->isOn()) {
|
||
_display->turnOn();
|
||
}
|
||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||
_next_refresh = 100;
|
||
}
|
||
|
||
void UITask::gotoAudiobookPlayer() {
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
if (audiobook_screen == nullptr) return; // No audio hardware
|
||
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
|
||
if (_display != NULL) {
|
||
abPlayer->enter(*_display);
|
||
}
|
||
setCurrScreen(audiobook_screen);
|
||
if (_display != NULL && !_display->isOn()) {
|
||
_display->turnOn();
|
||
}
|
||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||
_next_refresh = 100;
|
||
#endif
|
||
}
|
||
|
||
#ifdef 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;
|
||
}
|
||
|
||
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::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;
|
||
}
|
||
|
||
#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);
|
||
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() {
|
||
MapScreen* map = (MapScreen*)map_screen;
|
||
if (_display != NULL) {
|
||
map->enter(*_display);
|
||
}
|
||
setCurrScreen(map_screen);
|
||
if (_display != NULL && !_display->isOn()) {
|
||
_display->turnOn();
|
||
}
|
||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||
_next_refresh = 100;
|
||
}
|
||
#endif
|
||
|
||
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||
_next_refresh = 100; // trigger re-render
|
||
|
||
if (success) {
|
||
int cidx = ((RepeaterAdminScreen*)repeater_admin)->getContactIdx();
|
||
if (cidx >= 0) {
|
||
clearDMUnread(cidx);
|
||
|
||
// Room server login: redirect to conversation view with stored permissions.
|
||
// Admin users see L:Admin footer to access the admin panel.
|
||
// Skip redirect if user explicitly pressed L to get to admin.
|
||
if (!_skipRoomRedirect) {
|
||
ContactInfo contact;
|
||
if (the_mesh.getContactByIdx(cidx, contact) && contact.type == ADV_TYPE_ROOM) {
|
||
uint8_t maskedPerms = permissions & 0x03;
|
||
gotoDMConversation(contact.name, cidx, maskedPerms);
|
||
return;
|
||
}
|
||
}
|
||
_skipRoomRedirect = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
|
||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
|
||
_next_refresh = 100; // trigger re-render
|
||
}
|
||
}
|
||
|
||
void UITask::onAdminTelemetryResult(const uint8_t* data, uint8_t len) {
|
||
Serial.printf("[UITask] onAdminTelemetryResult: %d bytes, onAdmin=%d\n", len, isOnRepeaterAdmin());
|
||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||
((RepeaterAdminScreen*)repeater_admin)->onTelemetryResult(data, len);
|
||
_next_refresh = 100; // trigger re-render
|
||
}
|
||
}
|
||
|
||
#ifdef MECK_AUDIO_VARIANT
|
||
bool UITask::isAudioPlayingInBackground() const {
|
||
if (!audiobook_screen) return false;
|
||
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
|
||
return player->isAudioActive();
|
||
}
|
||
|
||
bool UITask::isAudioPausedInBackground() const {
|
||
if (!audiobook_screen) return false;
|
||
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
|
||
return player->isBookOpen() && !player->isAudioActive();
|
||
}
|
||
#endif |