Files
meshtastic_firmware/src/graphics/Screen.cpp
T
Jonathan BennettandGitHub 87bb149afb adds VARIANT_TOUCHSCREEN and ENABLE_TOUCH_INT (#10815)
* addsVARIANT_TOUCHSCREEN and ENABLE_TOUCH_INT

* Remove a duplicated _getTouch function pointer
2026-06-30 00:28:24 -05:00

2239 lines
84 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
BaseUI
Developed and Maintained By:
- Ronald Garcia (HarukiToreda) Lead development and implementation.
- JasonP (Xaositek) Screen layout and icon design, UI improvements and testing.
- TonyG (Tropho) Project management, structural planning, and testing
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "Screen.h"
#include "NodeDB.h"
#include "PowerMon.h"
#include "Throttle.h"
#include "configuration.h"
#include "meshUtils.h"
#if HAS_SCREEN
#include "EInkParallelDisplay.h"
#include <OLEDDisplay.h>
#include "DisplayFormatters.h"
#include "TimeFormatters.h"
#include "draw/ClockRenderer.h"
#include "draw/DebugRenderer.h"
#include "draw/MenuHandler.h"
#include "draw/MessageRenderer.h"
#include "draw/NodeListRenderer.h"
#include "draw/NotificationRenderer.h"
#include "draw/UIRenderer.h"
#include "graphics/TFTColorRegions.h"
#include "modules/CannedMessageModule.h"
#include "security/LockdownDisplay.h"
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
#include "buzz.h"
#endif
#include "FSCommon.h"
#include "MeshService.h"
#include "MessageStore.h"
#include "RadioLibInterface.h"
#include "SPILock.h"
#include "error.h"
#include "gps/GeoCoord.h"
#include "gps/RTC.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTPalette.h"
#include "graphics/emotes.h"
#include "graphics/images.h"
#include "input/TouchScreenImpl1.h"
#include "main.h"
#include "mesh-pb-constants.h"
#include "mesh/Channels.h"
#include "mesh/Default.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "modules/ExternalNotificationModule.h"
#include "modules/WaypointModule.h"
#include "sleep.h"
#include "target_specific.h"
extern MessageStore messageStore;
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
#include "mesh/wifi/WiFiAPClient.h"
#endif
#ifdef ARCH_ESP32
#endif
#if ARCH_PORTDUINO
#include "modules/StoreForwardModule.h"
#include "platform/portduino/PortduinoGlue.h"
#endif
#if defined(T_LORA_PAGER)
// KB backlight control
#include "input/cardKbI2cImpl.h"
#endif
using namespace meshtastic; /** @todo remove */
namespace graphics
{
// This means the *visible* area (sh1106 can address 132, but shows 128 for example)
#define IDLE_FRAMERATE 1 // in fps
#define COMPASS_ACTIVE_FRAMERATE 20
// DEBUG
#define NUM_EXTRA_FRAMES 3 // text message and debug frame
// if defined a pixel will blink to show redraws
// #define SHOW_REDRAWS
#define ASCII_BELL '\x07'
// A text message frame + debug frame + all the node infos
FrameCallback *normalFrames;
static uint32_t targetFramerate = IDLE_FRAMERATE;
#if GRAPHICS_TFT_COLORING_ENABLED
static inline void prepareFrameColorRegions()
{
#if GRAPHICS_TFT_COLORING_ENABLED
clearTFTColorRegions();
// Full-frame FrameMono inversion for themes that need it (e.g. light themes).
if (isThemeFullFrameInvert()) {
setAndRegisterTFTColorRole(TFTColorRole::FrameMono, getThemeBodyFg(), getThemeBodyBg(), 0, 0, screen->getWidth(),
screen->getHeight());
}
#endif
}
#endif
#ifdef MESHTASTIC_LOCKDOWN
// Static lock screen drawn in place of normal frames when
// meshtastic_security::shouldRedactDisplay() returns true. Renders centered
// "LOCKED" plus battery so the operator can see the device is alive and
// charged without leaking any node/channel/message/position content.
// Draw the LOCKED frame into the host-side framebuffer. Does NOT commit
// to the panel — the caller is responsible for calling display->display()
// once it has composited any overlays on top. Committing here would cause
// visible flicker between "just LOCKED" and "LOCKED + banner overlay" when
// the pairing-PIN special-case in updateUiFrame paints the overlay after
// this returns.
static void drawLockdownLockScreenIntoBuffer(OLEDDisplay *display)
{
display->clear();
const int w = display->getWidth();
const int h = display->getHeight();
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->setFont(FONT_LARGE);
display->drawString(w / 2, h / 2 - FONT_HEIGHT_LARGE, "LOCKED");
display->setFont(FONT_SMALL);
char status[32] = "Connect to unlock";
if (powerStatus && powerStatus->getHasBattery()) {
int pct = powerStatus->getBatteryChargePercent();
snprintf(status, sizeof(status), "Battery %d%%", pct);
}
display->drawString(w / 2, h / 2 + 2, status);
}
// Convenience wrapper for callers that want the LOCKED frame committed
// to the panel immediately and have no overlay to compose on top.
static void drawLockdownLockScreen(OLEDDisplay *display)
{
drawLockdownLockScreenIntoBuffer(display);
display->display();
}
#endif
static inline void updateUiFrame(OLEDDisplayUi *ui)
{
#ifdef MESHTASTIC_LOCKDOWN
if (meshtastic_security::shouldRedactDisplay() && screen != nullptr) {
OLEDDisplay *display = screen->getDisplayDevice();
// Paint LOCKED into the framebuffer WITHOUT committing. We commit
// exactly once at the bottom — after any overlay has been composed
// on top — so the panel never visibly transitions from "just LOCKED"
// to "LOCKED + overlay" mid-frame. Committing twice per cycle was
// the source of the H13 flicker.
drawLockdownLockScreenIntoBuffer(display);
// Special-case the BLE pairing PIN banner. The PIN is needed to
// complete first-pair against a locked device, but the lockdown
// short-circuit would otherwise hide the PIN entirely. The PIN is
// a per-attempt ephemeral pair-handshake artifact, not operator
// content, so compositing it over the LOCKED frame is safe.
//
// Calling ui->update() here would be wrong: it redraws the current
// carousel frame (the dashboard) into the framebuffer before the
// overlay paints, leaving operator content visible underneath the
// banner. Instead we invoke the banner overlay callback directly,
// which paints only the banner box on top of the LOCKED pixels we
// already have in the framebuffer.
if (NotificationRenderer::current_notification_type == notificationTypeEnum::pairing_pin) {
NotificationRenderer::drawBannercallback(display, ui->getUiState());
}
display->display();
return;
}
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
ui->update();
}
// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization
uint32_t logo_timeout = 5000; // 4 seconds for EACH logo
// Threshold values for the GPS lock accuracy bar display
uint32_t dopThresholds[5] = {2000, 1000, 500, 200, 100};
// At some point, we're going to ask all of the modules if they would like to display a screen frame
// we'll need to hold onto pointers for the modules that can draw a frame.
std::vector<MeshModule *> moduleFrames;
#if HAS_GPS
// GeoCoord object for the screen
GeoCoord geoCoord;
#endif
#ifdef SHOW_REDRAWS
static bool heartbeat = false;
#endif
#include "graphics/ScreenFonts.h"
#include <Throttle.h>
// Usage: int stringWidth = formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display);
// End Functions to write date/time to the screen
extern bool hasUnreadMessage;
static inline float wrapHeading360(float heading)
{
if (heading < 0.0f) {
heading += 360.0f;
} else if (heading >= 360.0f) {
heading -= 360.0f;
}
return heading;
}
static inline float wrapDelta180(float delta)
{
if (delta > 180.0f) {
delta -= 360.0f;
} else if (delta < -180.0f) {
delta += 360.0f;
}
return delta;
}
void Screen::setHeading(float heading)
{
const float wrappedHeading = wrapHeading360(heading);
if (!hasCompass) {
hasCompass = true;
compassHeading = wrappedHeading;
return;
}
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
float delta = wrapDelta180(wrappedHeading - compassHeading);
// Adaptive filtering:
// - Strong damping for tiny deltas (jitter)
// - Faster response for larger turns
const float absDelta = (delta >= 0.0f) ? delta : -delta;
if (absDelta >= 1.0f) {
float alpha = 0.35f;
if (absDelta > 25.0f) {
alpha = 0.85f;
} else if (absDelta > 10.0f) {
alpha = 0.65f;
}
float step = delta * alpha;
const float maxStep = 12.0f;
if (step > maxStep) {
step = maxStep;
} else if (step < -maxStep) {
step = -maxStep;
}
compassHeading = wrapHeading360(compassHeading + step);
}
}
// ==============================
// Overlay Alert Banner Renderer
// ==============================
// Displays a temporary centered banner message (e.g., warning, status, etc.)
// The banner appears in the center of the screen and disappears after the specified duration
void Screen::showSimpleBanner(const char *message, uint32_t durationMs)
{
BannerOverlayOptions options;
options.message = message;
options.durationMs = durationMs;
options.notificationType = notificationTypeEnum::text_banner;
showOverlayBanner(options);
}
// Called to trigger a banner with custom message and duration
void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options)
{
#ifdef USE_EINK
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus
#endif
// Store the message and set the expiration timestamp
strncpy(NotificationRenderer::alertBannerMessage, banner_overlay_options.message, 255);
NotificationRenderer::parseBannerMessageWithFonts(NotificationRenderer::alertBannerMessage);
NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
NotificationRenderer::alertBannerUntil =
(banner_overlay_options.durationMs == 0) ? 0 : millis() + banner_overlay_options.durationMs;
NotificationRenderer::optionsArrayPtr = banner_overlay_options.optionsArrayPtr;
NotificationRenderer::optionsEnumPtr = banner_overlay_options.optionsEnumPtr;
NotificationRenderer::alertBannerOptions = banner_overlay_options.optionsCount;
NotificationRenderer::alertBannerCallback = banner_overlay_options.bannerCallback;
NotificationRenderer::curSelected = banner_overlay_options.InitialSelected;
NotificationRenderer::pauseBanner = false;
NotificationRenderer::current_notification_type = banner_overlay_options.notificationType;
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, 2);
ui->setTargetFPS(60);
updateUiFrame(ui);
}
// Called to trigger a banner with custom message and duration
void Screen::showNodePicker(const char *message, uint32_t durationMs, std::function<void(uint32_t)> bannerCallback)
{
#ifdef USE_EINK
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus
#endif
nodeDB->pause_sort(true);
// Store the message and set the expiration timestamp
strncpy(NotificationRenderer::alertBannerMessage, message, 255);
NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
NotificationRenderer::alertBannerCallback = bannerCallback;
NotificationRenderer::pauseBanner = false;
NotificationRenderer::curSelected = 0;
NotificationRenderer::current_notification_type = notificationTypeEnum::node_picker;
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, 2);
ui->setTargetFPS(60);
updateUiFrame(ui);
}
// Called to trigger a banner with custom message and duration
void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits,
std::function<void(uint32_t)> bannerCallback)
{
#ifdef USE_EINK
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus
#endif
// Store the message and set the expiration timestamp
strncpy(NotificationRenderer::alertBannerMessage, message, 255);
NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
NotificationRenderer::alertBannerCallback = bannerCallback;
NotificationRenderer::pauseBanner = false;
NotificationRenderer::curSelected = 0;
NotificationRenderer::current_notification_type = notificationTypeEnum::number_picker;
NotificationRenderer::numDigits = digits;
NotificationRenderer::currentNumber = 0;
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, 2);
ui->setTargetFPS(60);
updateUiFrame(ui);
}
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
std::function<void(const std::string &)> textCallback)
{
LOG_INFO("showTextInput called with header='%s', durationMs=%d", header ? header : "NULL", durationMs);
// Start OnScreenKeyboardModule session (non-touch variant)
OnScreenKeyboardModule::instance().start(header, initialText, durationMs, textCallback);
NotificationRenderer::textInputCallback = textCallback;
// Store the message and set the expiration timestamp (use same pattern as other notifications)
strncpy(NotificationRenderer::alertBannerMessage, header ? header : "Text Input", 255);
NotificationRenderer::alertBannerMessage[255] = '\0';
NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
NotificationRenderer::pauseBanner = false;
NotificationRenderer::current_notification_type = notificationTypeEnum::text_input;
// Set the overlay using the same pattern as other notification types
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, 2);
ui->setTargetFPS(60);
updateUiFrame(ui);
}
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
uint8_t module_frame;
// there's a little but in the UI transition code
// where it invokes the function at the correct offset
// in the array of "drawScreen" functions; however,
// the passed-state doesn't quite reflect the "current"
// screen, so we have to detect it.
if (state->frameState == IN_TRANSITION && state->transitionFrameRelationship == TransitionRelationship_INCOMING) {
// if we're transitioning from the end of the frame list back around to the first
// frame, then we want this to be `0`
module_frame = state->transitionFrameTarget;
} else {
// otherwise, just display the module frame that's aligned with the current frame
module_frame = state->currentFrame;
}
MeshModule &pi = *moduleFrames.at(module_frame);
pi.drawFrame(display, state, x, y);
}
/**
* Given a recent lat/lon return a guess of the heading the user is walking on.
*
* We keep a series of "after you've gone 10 meters, what is your heading since
* the last reference point?"
*/
float Screen::estimatedHeading(double lat, double lon)
{
static double oldLat, oldLon;
static float b = -1.0f;
static uint32_t lastHeadingAtMs = 0;
const uint32_t now = millis();
const uint32_t gpsUpdateIntervalSecs =
Default::getConfiguredOrDefault(config.position.gps_update_interval, default_gps_update_interval);
uint32_t effectiveUpdateIntervalSecs = gpsUpdateIntervalSecs;
if (config.position.position_broadcast_smart_enabled) {
const uint32_t smartMinIntervalSecs = Default::getConfiguredOrDefault(
config.position.broadcast_smart_minimum_interval_secs, default_broadcast_smart_minimum_interval_secs);
if (smartMinIntervalSecs > effectiveUpdateIntervalSecs) {
effectiveUpdateIntervalSecs = smartMinIntervalSecs;
}
}
// Two expected update windows; keep arithmetic 32-bit to avoid pulling in larger 64-bit helpers.
const uint32_t headingStaleMs =
(effectiveUpdateIntervalSecs > (UINT32_MAX / 2000U)) ? UINT32_MAX : (effectiveUpdateIntervalSecs * 2000U);
if (oldLat == 0) {
// Need at least two position points before we can infer heading.
oldLat = lat;
oldLon = lon;
return b;
}
float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon);
if (d < 10) { // haven't moved enough, keep previous heading (invalid until first real movement)
if (lastHeadingAtMs != 0 && (now - lastHeadingAtMs) >= headingStaleMs) {
// Heading is stale after prolonged no-movement; force reacquire.
b = -1.0f;
oldLat = lat;
oldLon = lon;
}
return b;
}
b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG;
oldLat = lat;
oldLon = lon;
lastHeadingAtMs = now;
return b;
}
/// We will skip one node - the one for us, so we just blindly loop over all
/// nodes
static int8_t prevFrame = -1;
// Combined dynamic node list frame cycling through LastHeard, HopSignal, and Distance modes
// Uses a single frame and changes data every few seconds (E-Ink variant is separate)
#if defined(ESP_PLATFORM) && (defined(USE_ST7789) || defined(USE_ST7796))
SPIClass SPI1(HSPI);
#endif
Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_OledType screenType, OLEDDISPLAY_GEOMETRY geometry)
: concurrency::OSThread("Screen"), address_found(address), model(screenType), geometry(geometry), cmdQueue(32)
{
graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES];
#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64)
dispdev = new SH1106Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_ST7789)
#ifdef ESP_PLATFORM
dispdev = new ST7789Spi(&SPI1, ST7789_RESET, ST7789_RS, ST7789_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT, ST7789_SDA,
ST7789_MISO, ST7789_SCK);
#else
dispdev = new ST7789Spi(&SPI1, ST7789_RESET, ST7789_RS, ST7789_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT);
#endif
#elif defined(USE_ST7796)
#ifdef ESP_PLATFORM
dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT, ST7796_SDA,
ST7796_MISO, ST7796_SCK, TFT_SPI_FREQUENCY);
#else
dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT);
#endif
#elif defined(USE_SSD1306)
dispdev = new SSD1306Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#if defined(OLED_Y_OFFSET_PAGES)
// Panels whose active window does not start at GDDRAM row 0 (e.g. 72x40
// modules on pages 3..7) need a fixed vertical page shift on every write.
static_cast<SSD1306Wire *>(dispdev)->setYOffset(OLED_Y_OFFSET_PAGES);
#endif
#elif defined(USE_SPISSD1306)
dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48);
if (!dispdev->init()) {
LOG_DEBUG("Error: SSD1306 not detected!");
} else {
static_cast<SSD1306Spi *>(dispdev)->setHorizontalOffset(32);
LOG_INFO("SSD1306 init success");
}
#elif ARCH_PORTDUINO
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
if (portduino_config.displayPanel != no_screen) {
LOG_DEBUG("Make TFTDisplay!");
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
} else {
dispdev = new AutoOLEDWire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
isAUTOOled = true;
}
}
#elif USE_TFTDISPLAY
LOG_DEBUG("Make TFTDisplay!");
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) && !defined(USE_EINK_PARALLELDISPLAY)
dispdev = new EInkDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
dispdev = new EInkDynamicDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK_PARALLELDISPLAY)
dispdev = new EInkParallelDisplay(EPD_WIDTH, EPD_HEIGHT, EInkParallelDisplay::EPD_ROT_PORTRAIT);
#elif defined(USE_ST7567)
dispdev = new ST7567Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#else
dispdev = new AutoOLEDWire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
isAUTOOled = true;
#endif
#if defined(USE_ST7789)
// Keep firmware and ST7789 driver region structs layout-compatible:
// we pass `graphics::colorRegions` through a type cast below.
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
#elif defined(USE_ST7796)
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
#endif
ui = new OLEDDisplayUi(dispdev);
cmdQueue.setReader(this);
}
Screen::~Screen()
{
delete[] graphics::normalFrames;
}
/**
* Prepare the display for the unit going to the lowest power mode possible. Most screens will just
* poweroff, but eink screens will show a "I'm sleeping" graphic, possibly with a QR code
*/
void Screen::doDeepSleep()
{
#ifdef USE_EINK
setOn(false, graphics::UIRenderer::drawDeepSleepFrame);
#else
// Without E-Ink display:
setOn(false);
#endif
}
void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
{
if (!useDisplay)
return;
if (on != screenOn) {
if (on) {
LOG_INFO("Turn on screen");
powerMon->setState(meshtastic_PowerMon_State_Screen_On);
#ifdef T_WATCH_S3
PMU->enablePowerOutput(XPOWERS_ALDO2);
#endif
// some screens seem to need a kick in the pants to turn back on
#if defined(MUZI_BASE) || defined(M5STACK_CARDPUTER_ADV)
dispdev->init();
dispdev->setBrightness(brightness);
dispdev->flipScreenVertically();
dispdev->resetDisplay();
#ifdef SCREEN_12V_ENABLE
digitalWrite(SCREEN_12V_ENABLE, HIGH);
#endif
delay(100);
#endif
#if !ARCH_PORTDUINO
#if defined(USE_ST7789) && defined(VTFT_CTRL)
// Ensure panel power rail is enabled before sending wake commands.
pinMode(VTFT_CTRL, OUTPUT);
digitalWrite(VTFT_CTRL, LOW);
#endif
dispdev->displayOn();
#endif
#ifdef PIN_EINK_EN
if (uiconfig.screen_brightness == 1)
digitalWrite(PIN_EINK_EN, HIGH);
#elif defined(PCA_PIN_EINK_EN)
if (uiconfig.screen_brightness > 0)
io.digitalWrite(PCA_PIN_EINK_EN, HIGH);
#endif
#if defined(ST7789_CS) && \
!defined(M5STACK) // set display brightness when turning on screens. Just moved function from TFTDisplay to here.
static_cast<TFTDisplay *>(dispdev)->setDisplayBrightness(brightness);
#endif
dispdev->displayOn();
#if defined(HELTEC_TRACKER_V1_X) || defined(HELTEC_WIRELESS_TRACKER_V2)
ui->init();
#endif
#if defined(USE_ST7789) && defined(VTFT_LEDA)
ui->init();
#ifdef ESP_PLATFORM
analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT);
#else
pinMode(VTFT_LEDA, OUTPUT);
digitalWrite(VTFT_LEDA, TFT_BACKLIGHT_ON);
#endif
#endif
#ifdef USE_ST7796
ui->init();
#ifdef ESP_PLATFORM
analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT);
#else
pinMode(VTFT_LEDA, OUTPUT);
digitalWrite(VTFT_LEDA, TFT_BACKLIGHT_ON);
#endif
#endif
enabled = true;
setInterval(0); // Draw ASAP
runASAP = true;
} else {
powerMon->clearState(meshtastic_PowerMon_State_Screen_On);
#ifdef USE_EINK
// eInkScreensaver parameter is usually NULL (default argument), default frame used instead
setScreensaverFrames(einkScreensaver);
#endif
#ifdef MESHTASTIC_LOCKDOWN
// M19: before turning the panel off, paint a safe frame into the
// OLED's GDDRAM. The panel retains whatever was last written even
// while powered down, so when displayOn() is called later the
// screen would otherwise flash the previous frame's content for
// 16-50 ms before the next ui->update() lands. Painting the
// LOCKED frame now ensures the only thing the operator (or
// someone over their shoulder) can see on wake is the redacted
// view. Gated on lockdown — non-lockdown builds keep the
// previous frame as a UX cue that the display is just dimmed.
// dispdev is dereferenced unguarded throughout this file (incl.
// displayOff() just below), so no null check here.
drawLockdownLockScreen(dispdev);
#endif
#ifdef PIN_EINK_EN
digitalWrite(PIN_EINK_EN, LOW);
#elif defined(PCA_PIN_EINK_EN)
io.digitalWrite(PCA_PIN_EINK_EN, LOW);
#endif
dispdev->displayOff();
#ifdef SCREEN_12V_ENABLE
digitalWrite(SCREEN_12V_ENABLE, LOW);
#endif
#ifdef USE_ST7789
SPI1.end();
// Keep TFT control pins in deterministic states while timed-off.
// Floating/default pin states can corrupt panel edge rows on wake.
#ifdef VTFT_LEDA
pinMode(VTFT_LEDA, OUTPUT);
digitalWrite(VTFT_LEDA, !TFT_BACKLIGHT_ON);
#endif
#ifdef VTFT_CTRL
pinMode(VTFT_CTRL, OUTPUT);
digitalWrite(VTFT_CTRL, HIGH);
#endif
pinMode(ST7789_RESET, OUTPUT);
digitalWrite(ST7789_RESET, HIGH);
pinMode(ST7789_RS, OUTPUT);
digitalWrite(ST7789_RS, HIGH);
pinMode(ST7789_NSS, OUTPUT);
digitalWrite(ST7789_NSS, HIGH);
#endif
#ifdef USE_ST7796
SPI1.end();
#if defined(ARCH_ESP32)
pinMode(VTFT_LEDA, OUTPUT);
digitalWrite(VTFT_LEDA, LOW);
pinMode(ST7796_RESET, ANALOG);
pinMode(ST7796_RS, ANALOG);
pinMode(ST7796_NSS, ANALOG);
#else
nrf_gpio_cfg_default(VTFT_LEDA);
nrf_gpio_cfg_default(ST7796_RESET);
nrf_gpio_cfg_default(ST7796_RS);
nrf_gpio_cfg_default(ST7796_NSS);
#endif
#endif
#ifdef T_WATCH_S3
PMU->disablePowerOutput(XPOWERS_ALDO2);
#endif
enabled = false;
}
screenOn = on;
}
}
void Screen::setup()
{
// Enable display rendering
useDisplay = true;
// Load saved brightness from UI config
// For OLED displays (SSD1306), default brightness is 255 if not set
if (uiconfig.screen_brightness == 0) {
#if defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107)
brightness = 255; // Default for OLED
#else
brightness = BRIGHTNESS_DEFAULT;
#endif
} else {
brightness = uiconfig.screen_brightness;
}
// Restore which frames the user has hidden (persisted across reboots).
// Must happen before the first setFrames().
loadFrameVisibility();
// Detect OLED subtype (if supported by board variant)
#ifdef AutoOLEDWire_h
if (isAUTOOled)
static_cast<AutoOLEDWire *>(dispdev)->setDetected(model);
#endif
#if defined(USE_SH1107_128_64) || defined(USE_SH1107)
static_cast<SH1106Wire *>(dispdev)->setSubtype(7);
#endif
#if defined(USE_ST7789)
static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
"graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
static_cast<ST7789Spi *>(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
#endif
#if defined(MUZI_BASE)
dispdev->delayPoweron = true;
#endif
#if defined(USE_ST7796)
static_cast<ST7796Spi *>(dispdev)->setRGB(TFTPalette::White);
#endif
// Initialize display and UI system
ui->init();
displayWidth = dispdev->width();
displayHeight = dispdev->height();
ui->setTimePerTransition(0); // Disable animation delays
ui->setIndicatorPosition(BOTTOM); // Not used (indicators disabled below)
ui->setIndicatorDirection(LEFT_RIGHT); // Not used (indicators disabled below)
ui->setFrameAnimation(SLIDE_LEFT); // Used only when indicators are active
ui->disableAllIndicators(); // Disable page indicator dots
ui->getUiState()->userData = this; // Allow static callbacks to access Screen instance
// Apply loaded brightness
#if defined(ST7789_CS)
static_cast<TFTDisplay *>(dispdev)->setDisplayBrightness(brightness);
#elif defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SPISSD1306)
dispdev->setBrightness(brightness);
#endif
LOG_INFO("Applied screen brightness: %d", brightness);
#if defined(MESHTASTIC_LOCKDOWN) && defined(USE_EINK)
// M20: e-ink panels physically retain the last-rendered image without
// power, so a power-cycled lockdown handheld would keep showing
// operator-identifying content (position, messages, node info) until
// the firmware's first natural refresh — which on e-ink can be seconds
// into boot. Force a full refresh to the LOCKED frame here, immediately
// after the display is initialised and before any other rendering, so
// the persistent pixels are wiped to the redacted view before an
// observer can see them.
if (meshtastic_security::shouldRedactDisplay()) {
drawLockdownLockScreen(dispdev);
#if defined(USE_EINK_PARALLELDISPLAY)
// Parallel-display variants drive refresh through a different path;
// a bare drawLockdownLockScreen above lands the frame into the
// panel buffer and the next ui->update() commits it as normal.
#else
static_cast<EInkDisplay *>(dispdev)->forceDisplay();
#endif
}
#endif
// Set custom overlay callbacks
static OverlayCallback overlays[] = {
graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame
};
ui->setOverlays(overlays, 1);
// Enable UTF-8 to display mapping
dispdev->setFontTableLookupFunction(customFontTableLookup);
#ifdef USERPREFS_OEM_TEXT
logo_timeout *= 2; // Give more time for branded boot logos
#endif
// Configure alert frames (e.g., "Resuming..." or region name)
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip slow refresh
alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) {
#ifdef ARCH_ESP32
if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1)
graphics::UIRenderer::drawFrameText(display, state, x, y, "Resuming...");
else
#endif
{
const char *region = myRegion ? myRegion->name : nullptr;
graphics::UIRenderer::drawBootIconScreen(region, display, state, x, y);
}
};
ui->setFrames(alertFrames, 1);
ui->disableAutoTransition(); // Require manual navigation between frames
// Log buffer for on-screen logs (3 lines max)
dispdev->setLogBuffer(3, 32);
// Optional screen mirroring or flipping (e.g. for T-Beam orientation)
#ifdef SCREEN_MIRROR
dispdev->mirrorScreen();
#else
if (!config.display.flip_screen) {
#if USE_TFTDISPLAY && !ARCH_PORTDUINO
static_cast<TFTDisplay *>(dispdev)->flipScreenVertically();
#elif defined(USE_ST7789)
static_cast<ST7789Spi *>(dispdev)->flipScreenVertically();
#elif defined(USE_ST7796)
static_cast<ST7796Spi *>(dispdev)->mirrorScreen();
#elif !defined(M5STACK_UNITC6L)
dispdev->flipScreenVertically();
#endif
}
#endif
// Generate device ID from MAC address
uint8_t dmac[6];
getMacAddr(dmac);
snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]);
#if ARCH_PORTDUINO
handleSetOn(false); // Ensure proper init for Arduino targets
#endif
// Turn on display and trigger first draw
handleSetOn(true);
graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width());
updateUiFrame(ui);
#ifndef USE_EINK
updateUiFrame(ui); // Some SSD1306 clones drop the first draw, so run twice
#endif
serialSinceMsec = millis();
#if ARCH_PORTDUINO
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
if (portduino_config.touchscreenModule) {
touchScreenImpl1 =
new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast<TFTDisplay *>(dispdev)->getTouch);
touchScreenImpl1->init();
}
}
#elif HAS_TOUCHSCREEN && !defined(USE_EINK) && !VARIANT_TOUCHSCREEN
touchScreenImpl1 =
new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast<TFTDisplay *>(dispdev)->getTouch);
touchScreenImpl1->init();
#endif
// Subscribe to device status updates
powerStatusObserver.observe(&powerStatus->onNewStatus);
gpsStatusObserver.observe(&gpsStatus->onNewStatus);
nodeStatusObserver.observe(&nodeStatus->onNewStatus);
#if !MESHTASTIC_EXCLUDE_ADMIN
adminMessageObserver.observe(adminModule);
#endif
if (inputBroker)
inputObserver.observe(inputBroker);
// Load persisted messages into RAM
messageStore.loadFromFlash();
LOG_INFO("MessageStore loaded from flash");
// Notify modules that support UI events
MeshModule::observeUIEvents(&uiFrameEventObserver);
}
void Screen::setOn(bool on, FrameCallback einkScreensaver)
{
#if defined(T_LORA_PAGER)
if (cardKbI2cImpl)
cardKbI2cImpl->toggleBacklight(on);
#endif
if (!on) {
#ifdef MESHTASTIC_LOCKDOWN
// Screen powering off (idle timeout, shutdown, deep sleep) latches
// the screen-lock. Next time the display wakes it shows the LOCKED
// frame until a client authenticates with the passphrase.
meshtastic_security::lockScreen();
#endif
// We handle off commands immediately, because they might be called because the CPU is shutting down
handleSetOn(false, einkScreensaver);
} else
enqueueCmd(ScreenCmd{.cmd = Cmd::SET_ON});
}
void Screen::forceDisplay(bool forceUiUpdate)
{
// Nasty hack to force epaper updates for 'key' frames. FIXME, cleanup.
#ifdef USE_EINK
// If requested, make sure queued commands are run, and UI has rendered a new frame
if (forceUiUpdate) {
// Force a display refresh, in addition to the UI update
// Changing the GPS status bar icon apparently doesn't register as a change in image
// (False negative of the image hashing algorithm used to skip identical frames)
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST);
// No delay between UI frame rendering
setFastFramerate();
// Make sure all CMDs have run first
while (!cmdQueue.isEmpty())
runOnce();
// Ensure at least one frame has drawn
uint64_t startUpdate;
do {
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
delay(10);
updateUiFrame(ui);
} while (ui->getUiState()->lastUpdate < startUpdate);
// Return to normal frame rate
targetFramerate = IDLE_FRAMERATE;
ui->setTargetFPS(targetFramerate);
}
// Tell EInk class to update the display
#if defined(USE_EINK_PARALLELDISPLAY)
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay();
#elif defined(USE_EINK)
static_cast<EInkDisplay *>(dispdev)->forceDisplay();
#endif
#else
// No delay between UI frame rendering
if (forceUiUpdate) {
setFastFramerate();
}
#endif
}
static uint32_t lastScreenTransition;
int32_t Screen::runOnce()
{
// If we don't have a screen, don't ever spend any CPU for us.
if (!useDisplay) {
enabled = false;
return RUN_SAME;
}
if (displayHeight == 0) {
displayHeight = dispdev->getHeight();
}
// Detect frame transitions and clear message cache when leaving text message screen
{
static int8_t lastFrameIndex = -1;
int8_t currentFrameIndex = ui->getUiState()->currentFrame;
int8_t textMsgIndex = framesetInfo.positions.textMessage;
if (lastFrameIndex != -1 && currentFrameIndex != lastFrameIndex) {
if (lastFrameIndex == textMsgIndex && currentFrameIndex != textMsgIndex) {
graphics::MessageRenderer::clearMessageCache();
}
}
lastFrameIndex = currentFrameIndex;
}
menuHandler::handleMenuSwitch(dispdev);
// Show boot screen for first logo_timeout seconds, then switch to normal operation.
// serialSinceMsec adjusts for additional serial wait time during nRF52 bootup
static bool showingBootScreen = true;
if (showingBootScreen && (millis() > (logo_timeout + serialSinceMsec))) {
LOG_INFO("Done with boot screen");
stopBootScreen();
showingBootScreen = false;
}
#ifdef USERPREFS_OEM_TEXT
static bool showingOEMBootScreen = true;
if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) {
LOG_INFO("Switch to OEM screen...");
// Change frames.
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
ui->setFrames(bootOEMFrames, bootOEMFrameCount);
updateUiFrame(ui);
#ifndef USE_EINK
updateUiFrame(ui);
#endif
showingOEMBootScreen = false;
}
#endif
#ifndef DISABLE_WELCOME_UNSET
bool suppressRegionOnboard = false;
#ifdef MESHTASTIC_LOCKDOWN
// While lockdown is active and storage is still locked, config.lora.region
// is a deliberate UNSET placeholder — the real region lives in encrypted
// storage and is restored on unlock (see NodeDB's locked-boot path). Don't
// pop the region picker over the lock screen: it would trap input, and the
// operator can't set a region until they unlock anyway.
suppressRegionOnboard = meshtastic_security::shouldRedactDisplay();
#endif
if (!suppressRegionOnboard && !NotificationRenderer::isOverlayBannerShowing() &&
config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if defined(OLED_TINY)
menuHandler::LoraRegionPicker();
#else
menuHandler::OnboardMessage();
#endif
}
#endif
if (!NotificationRenderer::isOverlayBannerShowing() && rebootAtMsec != 0 && !suppressRebootBanner) {
showSimpleBanner("Rebooting...", 0);
}
// Process incoming commands.
for (;;) {
ScreenCmd cmd;
if (!cmdQueue.dequeue(&cmd, 0)) {
break;
}
switch (cmd.cmd) {
case Cmd::SET_ON:
handleSetOn(true);
break;
case Cmd::SET_OFF:
handleSetOn(false);
break;
case Cmd::ON_PRESS:
if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) {
showFrame(FrameDirection::NEXT);
}
break;
case Cmd::SHOW_PREV_FRAME:
if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) {
showFrame(FrameDirection::PREVIOUS);
}
break;
case Cmd::SHOW_NEXT_FRAME:
if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) {
showFrame(FrameDirection::NEXT);
}
break;
case Cmd::START_ALERT_FRAME: {
showingBootScreen = false; // this should avoid the edge case where an alert triggers before the boot screen goes away
showingNormalScreen = false;
NotificationRenderer::pauseBanner = true;
alertFrames[0] = alertFrame;
#ifdef USE_EINK
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please
EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update
handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?)
#endif
setFrameImmediateDraw(alertFrames);
break;
}
case Cmd::START_FIRMWARE_UPDATE_SCREEN:
handleStartFirmwareUpdateScreen();
break;
case Cmd::STOP_ALERT_FRAME:
NotificationRenderer::pauseBanner = false;
// Return from one-off alert mode back to regular frames.
if (!showingNormalScreen && NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) {
setFrames();
}
break;
case Cmd::STOP_BOOT_SCREEN:
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame
if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) {
setFrames();
}
break;
case Cmd::NOOP:
break;
default:
LOG_ERROR("Invalid screen cmd");
}
}
if (!screenOn) { // If we didn't just wake and the screen is still off, then
// stop updating until it is on again
enabled = false;
return 0;
}
// this must be before the frameState == FIXED check, because we always
// want to draw at least one FIXED frame before doing forceDisplay
updateUiFrame(ui);
// Switch to a low framerate (to save CPU) when we are not in transition
// but we should only call setTargetFPS when framestate changes, because
// otherwise that breaks animations.
uint32_t desiredFramerate = IDLE_FRAMERATE;
#if HAS_GPS && !defined(USE_EINK)
if (showingNormalScreen && hasCompass) {
const uint8_t currentFrame = ui->getUiState()->currentFrame;
if ((framesetInfo.positions.gps != 255 && currentFrame == framesetInfo.positions.gps) ||
(framesetInfo.positions.waypoint != 255 && currentFrame == framesetInfo.positions.waypoint) ||
(framesetInfo.positions.firstFavorite != 255 && currentFrame >= framesetInfo.positions.firstFavorite &&
currentFrame <= framesetInfo.positions.lastFavorite)) {
desiredFramerate = COMPASS_ACTIVE_FRAMERATE;
}
}
#endif
if (targetFramerate != desiredFramerate && ui->getUiState()->frameState == FIXED) {
// oldFrameState = ui->getUiState()->frameState;
targetFramerate = desiredFramerate;
ui->setTargetFPS(targetFramerate);
forceDisplay();
}
// While showing the bootscreen or Bluetooth pair screen all of our
// standard screen switching is stopped.
if (showingNormalScreen) {
// standard screen loop handling here
if (config.display.auto_screen_carousel_secs > 0 &&
NotificationRenderer::current_notification_type != notificationTypeEnum::text_input &&
!Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) {
// If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead
// Carousel is potentially a major source of E-Ink display wear
#if !defined(EINK_BACKGROUND_USES_FAST)
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC);
#endif
LOG_DEBUG("LastScreenTransition exceeded %ums transition to next frame", (millis() - lastScreenTransition));
handleOnPress();
}
}
// LOG_DEBUG("want fps %d, fixed=%d", targetFramerate,
// ui->getUiState()->frameState); If we are scrolling we need to be called
// soon, otherwise just 1 fps (to save CPU) We also ask to be called twice
// as fast as we really need so that any rounding errors still result with
// the correct framerate
return (1000 / targetFramerate);
}
/* show a message that the SSL cert is being built
* it is expected that this will be used during the boot phase */
void Screen::setSSLFrames()
{
if (address_found.address) {
// LOG_DEBUG("Show SSL frames");
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
ui->setFrames(sslFrames, 1);
updateUiFrame(ui);
}
}
#ifdef USE_EINK
/// Determine which screensaver frame to use, then set the FrameCallback
void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
{
// Retain specified frame / overlay callback beyond scope of this method
static FrameCallback screensaverFrame;
static OverlayCallback screensaverOverlay;
#if defined(HAS_EINK_ASYNCFULL) && defined(USE_EINK_DYNAMICDISPLAY)
// Join (await) a currently running async refresh, then run the post-update code.
// Avoid skipping of screensaver frame. Would otherwise be handled by NotifiedWorkerThread.
EINK_JOIN_ASYNCREFRESH(dispdev);
#endif
// If: one-off screensaver frame passed as argument. Handles doDeepSleep()
if (einkScreensaver != NULL) {
screensaverFrame = einkScreensaver;
ui->setFrames(&screensaverFrame, 1);
}
// Else, display the usual "overlay" screensaver
else {
screensaverOverlay = graphics::UIRenderer::drawScreensaverOverlay;
ui->setOverlays(&screensaverOverlay, 1);
}
// Request new frame, ASAP
setFastFramerate();
uint64_t startUpdate;
do {
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
delay(1);
updateUiFrame(ui);
} while (ui->getUiState()->lastUpdate < startUpdate);
#if defined(USE_EINK_PARALLELDISPLAY)
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay(0);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY)
// Old EInkDisplay class
static_cast<EInkDisplay *>(dispdev)->forceDisplay(0); // Screen::forceDisplay(), but override rate-limit
#endif
// Prepare now for next frame, shown when display wakes
ui->setOverlays(NULL, 0); // Clear overlay
setFrames(FOCUS_PRESERVE); // Return to normal display updates, showing same frame as before screensaver, ideally
// Pick a refresh method, for when display wakes
#ifdef EINK_HASQUIRK_GHOSTING
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // Really ugly to see ghosting from "screen paused"
#else
EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh
#endif
}
#endif
// Regenerate the normal set of frames, focusing a specific frame if requested
// Called when a frame should be added / removed, or custom frames should be cleared
void Screen::setFrames(FrameFocus focus)
{
// Block setFrames calls when virtual keyboard is active to prevent overlay interference
if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) {
return;
}
uint8_t originalPosition = ui->getUiState()->currentFrame;
uint8_t previousFrameCount = framesetInfo.frameCount;
FramesetInfo fsi; // Location of specific frames, for applying focus parameter
graphics::UIRenderer::rebuildFavoritedNodes();
LOG_DEBUG("Show standard frames");
showingNormalScreen = true;
indicatorIcons.clear();
size_t numframes = 0;
// If we have a critical fault, show it first
fsi.positions.fault = numframes;
if (error_code) {
normalFrames[numframes++] = NotificationRenderer::drawCriticalFaultFrame;
indicatorIcons.push_back(icon_error);
focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame
}
#if defined(DISPLAY_CLOCK_FRAME)
if (!hiddenFrames.clock) {
fsi.positions.clock = numframes;
#if defined(OLED_TINY)
normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame;
#else
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
: graphics::ClockRenderer::drawDigitalClockFrame;
#endif
indicatorIcons.push_back(digital_icon_clock);
}
#endif
if (!hiddenFrames.home) {
fsi.positions.home = numframes;
normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused;
indicatorIcons.push_back(icon_home);
}
fsi.positions.textMessage = numframes;
normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame;
indicatorIcons.push_back(icon_mail);
#ifndef USE_EINK
if (!hiddenFrames.nodelist_nodes) {
fsi.positions.nodelist_nodes = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Nodes;
indicatorIcons.push_back(icon_nodes);
}
if (!hiddenFrames.nodelist_location) {
fsi.positions.nodelist_location = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Location;
indicatorIcons.push_back(icon_list);
}
#endif
// Show detailed node views only on E-Ink builds
#ifdef USE_EINK
if (!hiddenFrames.nodelist_lastheard) {
fsi.positions.nodelist_lastheard = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen;
indicatorIcons.push_back(icon_nodes);
}
if (!hiddenFrames.nodelist_hopsignal) {
fsi.positions.nodelist_hopsignal = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen;
indicatorIcons.push_back(icon_signal);
}
if (!hiddenFrames.nodelist_distance) {
fsi.positions.nodelist_distance = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen;
indicatorIcons.push_back(icon_distance);
}
#endif
#if HAS_GPS
#ifdef USE_EINK
if (!hiddenFrames.nodelist_bearings) {
fsi.positions.nodelist_bearings = numframes;
normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses;
indicatorIcons.push_back(icon_list);
}
#endif
if (!hiddenFrames.gps) {
fsi.positions.gps = numframes;
normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen;
indicatorIcons.push_back(icon_compass);
}
#endif
if (RadioLibInterface::instance && !hiddenFrames.lora) {
fsi.positions.lora = numframes;
normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused;
indicatorIcons.push_back(icon_radio);
}
if (!hiddenFrames.system) {
fsi.positions.system = numframes;
normalFrames[numframes++] = graphics::DebugRenderer::drawSystemScreen;
indicatorIcons.push_back(icon_system);
}
#if !defined(DISPLAY_CLOCK_FRAME)
if (!hiddenFrames.clock) {
fsi.positions.clock = numframes;
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
: graphics::ClockRenderer::drawDigitalClockFrame;
indicatorIcons.push_back(digital_icon_clock);
}
#endif
if (!hiddenFrames.chirpy) {
fsi.positions.chirpy = numframes;
normalFrames[numframes++] = graphics::DebugRenderer::drawChirpy;
indicatorIcons.push_back(chirpy_small);
}
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
if (!hiddenFrames.wifi && isWifiAvailable()) {
fsi.positions.wifi = numframes;
normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoWiFiTrampoline;
indicatorIcons.push_back(icon_wifi);
}
#endif
// Beware of what changes you make in this code!
// We pass numframes into GetMeshModulesWithUIFrames() which is highly important!
// Inside of that callback, goes over to MeshModule.cpp and we run
// modulesWithUIFrames.resize(startIndex, nullptr), to insert nullptr
// entries until we're ready to start building the matching entries.
// We are doing our best to keep the normalFrames vector
// and the moduleFrames vector in lock step.
moduleFrames = MeshModule::GetMeshModulesWithUIFrames(numframes);
LOG_DEBUG("Show %d module frames", moduleFrames.size());
for (auto i = moduleFrames.begin(); i != moduleFrames.end(); ++i) {
// Draw the module frame, using the hack described above
if (*i != nullptr) {
normalFrames[numframes] = drawModuleFrame;
// Check if the module being drawn has requested focus
// We will honor this request later, if setFrames was triggered by a UIFrameEvent
MeshModule *m = *i;
if (m && m->isRequestingFocus())
fsi.positions.focusedModule = numframes;
if (m && m == waypointModule)
fsi.positions.waypoint = numframes;
indicatorIcons.push_back(icon_module);
numframes++;
}
}
LOG_DEBUG("Added modules. numframes: %d", numframes);
// We don't show the node info of our node (if we have it yet - we should)
size_t numMeshNodes = nodeDB->getNumMeshNodes();
if (numMeshNodes > 0)
numMeshNodes--;
if (!hiddenFrames.show_favorites) {
// Temporary array to hold favorite node frames
std::vector<FrameCallback> favoriteFrames;
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
if (n && n->num != nodeDB->getNodeNum() && nodeInfoLiteIsFavorite(n)) {
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
}
}
// Insert favorite frames *after* collecting them all
if (!favoriteFrames.empty()) {
fsi.positions.firstFavorite = numframes;
for (const auto &f : favoriteFrames) {
normalFrames[numframes++] = f;
indicatorIcons.push_back(icon_node);
}
fsi.positions.lastFavorite = numframes - 1;
} else {
fsi.positions.firstFavorite = 255;
fsi.positions.lastFavorite = 255;
}
}
fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE
this->frameCount = numframes; // Save frame count for use in custom overlay
LOG_DEBUG("Finished build frames. numframes: %d", numframes);
ui->setFrames(normalFrames, numframes);
ui->disableAllIndicators();
// Add overlays: frame icons and alert banner)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, 2);
prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed)
// Focus on a specific frame, in the frame set we just created
switch (focus) {
case FOCUS_DEFAULT:
ui->switchToFrame(fsi.positions.deviceFocused);
break;
case FOCUS_FAULT:
ui->switchToFrame(fsi.positions.fault);
break;
case FOCUS_MODULE:
// Whichever frame was marked by MeshModule::requestFocus(), if any
// If no module requested focus, will show the first frame instead
ui->switchToFrame(fsi.positions.focusedModule);
break;
case FOCUS_CLOCK:
// Whichever frame was marked by MeshModule::requestFocus(), if any
// If no module requested focus, will show the first frame instead
ui->switchToFrame(fsi.positions.clock);
break;
case FOCUS_SYSTEM:
ui->switchToFrame(fsi.positions.system);
break;
case FOCUS_PRESERVE:
// No more adjustment — force stay on same index
if (previousFrameCount > fsi.frameCount) {
ui->switchToFrame(originalPosition - 1);
} else if (previousFrameCount < fsi.frameCount) {
ui->switchToFrame(originalPosition + 1);
} else {
ui->switchToFrame(originalPosition);
}
break;
}
// Store the info about this frameset, for future setFrames calls
this->framesetInfo = fsi;
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("rebuild", ui->getUiState()->currentFrame);
#endif
setFastFramerate(); // Draw ASAP
}
void Screen::setFrameImmediateDraw(FrameCallback *drawFrames)
{
ui->disableAllIndicators();
ui->setFrames(drawFrames, 1);
setFastFramerate();
}
void Screen::toggleFrameVisibility(const std::string &frameName)
{
#ifndef USE_EINK
if (frameName == "nodelist_nodes") {
hiddenFrames.nodelist_nodes = !hiddenFrames.nodelist_nodes;
}
if (frameName == "nodelist_location") {
hiddenFrames.nodelist_location = !hiddenFrames.nodelist_location;
}
#endif
#ifdef USE_EINK
if (frameName == "nodelist_lastheard") {
hiddenFrames.nodelist_lastheard = !hiddenFrames.nodelist_lastheard;
}
if (frameName == "nodelist_hopsignal") {
hiddenFrames.nodelist_hopsignal = !hiddenFrames.nodelist_hopsignal;
}
if (frameName == "nodelist_distance") {
hiddenFrames.nodelist_distance = !hiddenFrames.nodelist_distance;
}
#endif
#if HAS_GPS
#ifdef USE_EINK
if (frameName == "nodelist_bearings") {
hiddenFrames.nodelist_bearings = !hiddenFrames.nodelist_bearings;
}
#endif
if (frameName == "gps") {
hiddenFrames.gps = !hiddenFrames.gps;
}
#endif
if (frameName == "lora") {
hiddenFrames.lora = !hiddenFrames.lora;
}
if (frameName == "clock") {
hiddenFrames.clock = !hiddenFrames.clock;
}
if (frameName == "show_favorites") {
hiddenFrames.show_favorites = !hiddenFrames.show_favorites;
}
if (frameName == "chirpy") {
hiddenFrames.chirpy = !hiddenFrames.chirpy;
}
// Save the new visibility state so it survives a reboot.
saveFrameVisibility();
}
bool Screen::isFrameHidden(const std::string &frameName) const
{
#ifndef USE_EINK
if (frameName == "nodelist_nodes")
return hiddenFrames.nodelist_nodes;
if (frameName == "nodelist_location")
return hiddenFrames.nodelist_location;
#endif
#ifdef USE_EINK
if (frameName == "nodelist_lastheard")
return hiddenFrames.nodelist_lastheard;
if (frameName == "nodelist_hopsignal")
return hiddenFrames.nodelist_hopsignal;
if (frameName == "nodelist_distance")
return hiddenFrames.nodelist_distance;
#endif
#if HAS_GPS
#ifdef USE_EINK
if (frameName == "nodelist_bearings")
return hiddenFrames.nodelist_bearings;
#endif
if (frameName == "gps")
return hiddenFrames.gps;
#endif
if (frameName == "lora")
return hiddenFrames.lora;
if (frameName == "clock")
return hiddenFrames.clock;
if (frameName == "show_favorites")
return hiddenFrames.show_favorites;
if (frameName == "chirpy")
return hiddenFrames.chirpy;
return false;
}
// ---------------------------------------------------------------------------
// Frame visibility persistence
//
// The set of hideable frames varies by build (USE_EINK, HAS_GPS, ...), so we
// serialize to a fixed bitmask where each frame name owns a permanent bit
// position. Bits for frames that don't exist in the current build are simply
// left untouched, which keeps the saved file portable across firmware variants.
// ---------------------------------------------------------------------------
namespace
{
static const char *frameVisibilityFileName = "/prefs/framevis";
constexpr uint32_t FRAMEVIS_MAGIC = 0x53495646; // "FVIS" little-endian
constexpr uint8_t FRAMEVIS_VERSION = 1;
// Permanent bit assignments. Never renumber these; only append new ones.
enum FrameVisBit : uint8_t {
FVBIT_TEXT_MESSAGE = 0,
FVBIT_WAYPOINT = 1,
FVBIT_WIFI = 2,
FVBIT_SYSTEM = 3,
FVBIT_HOME = 4,
FVBIT_CLOCK = 5,
FVBIT_NODELIST_NODES = 6,
FVBIT_NODELIST_LOCATION = 7,
FVBIT_NODELIST_LASTHEARD = 8,
FVBIT_NODELIST_HOPSIGNAL = 9,
FVBIT_NODELIST_DISTANCE = 10,
FVBIT_NODELIST_BEARINGS = 11,
FVBIT_GPS = 12,
FVBIT_LORA = 13,
FVBIT_SHOW_FAVORITES = 14,
FVBIT_CHIRPY = 15,
};
struct __attribute__((packed)) FrameVisFile {
uint32_t magic;
uint8_t version;
uint32_t mask;
};
inline void setBit(uint32_t &mask, uint8_t bit, bool value)
{
if (value)
mask |= (1UL << bit);
else
mask &= ~(1UL << bit);
}
inline bool getBit(uint32_t mask, uint8_t bit)
{
return (mask & (1UL << bit)) != 0;
}
} // namespace
uint32_t Screen::packHiddenFrames() const
{
uint32_t mask = 0;
setBit(mask, FVBIT_TEXT_MESSAGE, hiddenFrames.textMessage);
setBit(mask, FVBIT_WAYPOINT, hiddenFrames.waypoint);
setBit(mask, FVBIT_WIFI, hiddenFrames.wifi);
setBit(mask, FVBIT_SYSTEM, hiddenFrames.system);
setBit(mask, FVBIT_HOME, hiddenFrames.home);
setBit(mask, FVBIT_CLOCK, hiddenFrames.clock);
#ifndef USE_EINK
setBit(mask, FVBIT_NODELIST_NODES, hiddenFrames.nodelist_nodes);
setBit(mask, FVBIT_NODELIST_LOCATION, hiddenFrames.nodelist_location);
#endif
#ifdef USE_EINK
setBit(mask, FVBIT_NODELIST_LASTHEARD, hiddenFrames.nodelist_lastheard);
setBit(mask, FVBIT_NODELIST_HOPSIGNAL, hiddenFrames.nodelist_hopsignal);
setBit(mask, FVBIT_NODELIST_DISTANCE, hiddenFrames.nodelist_distance);
#endif
#if HAS_GPS
#ifdef USE_EINK
setBit(mask, FVBIT_NODELIST_BEARINGS, hiddenFrames.nodelist_bearings);
#endif
setBit(mask, FVBIT_GPS, hiddenFrames.gps);
#endif
setBit(mask, FVBIT_LORA, hiddenFrames.lora);
setBit(mask, FVBIT_SHOW_FAVORITES, hiddenFrames.show_favorites);
setBit(mask, FVBIT_CHIRPY, hiddenFrames.chirpy);
return mask;
}
void Screen::applyHiddenFramesMask(uint32_t mask)
{
hiddenFrames.textMessage = getBit(mask, FVBIT_TEXT_MESSAGE);
hiddenFrames.waypoint = getBit(mask, FVBIT_WAYPOINT);
hiddenFrames.wifi = getBit(mask, FVBIT_WIFI);
hiddenFrames.system = getBit(mask, FVBIT_SYSTEM);
hiddenFrames.home = getBit(mask, FVBIT_HOME);
hiddenFrames.clock = getBit(mask, FVBIT_CLOCK);
#ifndef USE_EINK
hiddenFrames.nodelist_nodes = getBit(mask, FVBIT_NODELIST_NODES);
hiddenFrames.nodelist_location = getBit(mask, FVBIT_NODELIST_LOCATION);
#endif
#ifdef USE_EINK
hiddenFrames.nodelist_lastheard = getBit(mask, FVBIT_NODELIST_LASTHEARD);
hiddenFrames.nodelist_hopsignal = getBit(mask, FVBIT_NODELIST_HOPSIGNAL);
hiddenFrames.nodelist_distance = getBit(mask, FVBIT_NODELIST_DISTANCE);
#endif
#if HAS_GPS
#ifdef USE_EINK
hiddenFrames.nodelist_bearings = getBit(mask, FVBIT_NODELIST_BEARINGS);
#endif
hiddenFrames.gps = getBit(mask, FVBIT_GPS);
#endif
hiddenFrames.lora = getBit(mask, FVBIT_LORA);
hiddenFrames.show_favorites = getBit(mask, FVBIT_SHOW_FAVORITES);
hiddenFrames.chirpy = getBit(mask, FVBIT_CHIRPY);
}
void Screen::loadFrameVisibility()
{
#ifdef FSCom
spiLock->lock();
auto file = FSCom.open(frameVisibilityFileName, FILE_O_READ);
if (file) {
FrameVisFile data{};
bool ok = file.read((uint8_t *)&data, sizeof(data)) == sizeof(data) && data.magic == FRAMEVIS_MAGIC &&
data.version == FRAMEVIS_VERSION;
file.close();
spiLock->unlock();
if (ok) {
applyHiddenFramesMask(data.mask);
LOG_INFO("Loaded frame visibility (mask 0x%08x)", data.mask);
} else {
LOG_WARN("Frame visibility file invalid, keeping defaults");
}
return;
}
spiLock->unlock();
LOG_DEBUG("No saved frame visibility, using defaults");
#endif
}
void Screen::saveFrameVisibility()
{
#ifdef FSCom
spiLock->lock();
FSCom.mkdir("/prefs");
if (FSCom.exists(frameVisibilityFileName))
FSCom.remove(frameVisibilityFileName);
auto file = FSCom.open(frameVisibilityFileName, FILE_O_WRITE);
if (file) {
FrameVisFile data{};
data.magic = FRAMEVIS_MAGIC;
data.version = FRAMEVIS_VERSION;
data.mask = packHiddenFrames();
file.write((uint8_t *)&data, sizeof(data));
file.flush();
file.close();
LOG_INFO("Saved frame visibility (mask 0x%08x)", data.mask);
} else {
LOG_WARN("Failed to open %s for writing", frameVisibilityFileName);
}
spiLock->unlock();
#endif
}
void Screen::handleStartFirmwareUpdateScreen()
{
LOG_DEBUG("Show firmware screen");
showingNormalScreen = false;
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // E-Ink: Explicitly use fast-refresh for next frame
static FrameCallback frames[] = {graphics::NotificationRenderer::drawFrameFirmware};
setFrameImmediateDraw(frames);
}
void Screen::blink()
{
#ifdef MESHTASTIC_LOCKDOWN
// L4: defensive guard. blink() paints arbitrary geometry, not node
// data, so it doesn't actually leak today. But it bypasses the normal
// ui->update() path that the lockdown short-circuit gates, so any
// future change that puts content into blink would silently leak past
// redaction. Refuse to draw when the redaction latch is set.
if (meshtastic_security::shouldRedactDisplay())
return;
#endif
setFastFramerate();
uint8_t count = 10;
dispdev->setBrightness(254);
while (count > 0) {
dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight());
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
dispdev->display();
delay(50);
dispdev->clear();
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
dispdev->display();
delay(50);
count = count - 1;
}
// The dispdev->setBrightness does not work for t-deck display, it seems to run the setBrightness function in
// OLEDDisplay.
dispdev->setBrightness(brightness);
}
void Screen::increaseBrightness()
{
brightness = ((brightness + 62) > 254) ? brightness : (brightness + 62);
#if defined(ST7789_CS)
// run the setDisplayBrightness function. This works on t-decks
static_cast<TFTDisplay *>(dispdev)->setDisplayBrightness(brightness);
#endif
/* TO DO: add little popup in center of screen saying what brightness level it is set to*/
}
void Screen::decreaseBrightness()
{
brightness = (brightness < 70) ? brightness : (brightness - 62);
#if defined(ST7789_CS)
static_cast<TFTDisplay *>(dispdev)->setDisplayBrightness(brightness);
#endif
/* TO DO: add little popup in center of screen saying what brightness level it is set to*/
}
void Screen::handleOnPress()
{
// If screen was off, just wake it, otherwise advance to next frame
// If we are in a transition, the press must have bounced, drop it.
if (ui->getUiState()->frameState == FIXED) {
ui->nextFrame();
lastScreenTransition = millis();
setFastFramerate();
}
}
#ifdef USERPREFS_UI_TEST_LOG
void Screen::logFrameChange(const char *reason, uint8_t targetIdx)
{
// Reverse-map an index to a stable name string keyed off FramePositions
// field names — so the pytest harness can assert `name=nodelist_nodes`
// without caring about how the positions were ordered this boot.
const auto &p = framesetInfo.positions;
const char *name = "unknown";
if (targetIdx == p.home)
name = "home";
else if (targetIdx == p.deviceFocused)
name = "deviceFocused";
else if (targetIdx == p.textMessage)
name = "textMessage";
else if (targetIdx == p.nodelist_nodes)
name = "nodelist_nodes";
else if (targetIdx == p.nodelist_location)
name = "nodelist_location";
else if (targetIdx == p.nodelist_lastheard)
name = "nodelist_lastheard";
else if (targetIdx == p.nodelist_hopsignal)
name = "nodelist_hopsignal";
else if (targetIdx == p.nodelist_distance)
name = "nodelist_distance";
else if (targetIdx == p.nodelist_bearings)
name = "nodelist_bearings";
else if (targetIdx == p.system)
name = "system";
else if (targetIdx == p.gps)
name = "gps";
else if (targetIdx == p.lora)
name = "lora";
else if (targetIdx == p.clock)
name = "clock";
else if (targetIdx == p.chirpy)
name = "chirpy";
else if (targetIdx == p.fault)
name = "fault";
else if (targetIdx == p.waypoint)
name = "waypoint";
else if (targetIdx == p.focusedModule)
name = "focusedModule";
else if (targetIdx == p.log)
name = "log";
else if (targetIdx == p.settings)
name = "settings";
else if (targetIdx == p.wifi)
name = "wifi";
else if (p.firstFavorite != 255 && p.lastFavorite != 255 && targetIdx >= p.firstFavorite && targetIdx <= p.lastFavorite)
name = "favorite";
LOG_INFO("Screen: frame %u/%u name=%s reason=%s", (unsigned)targetIdx, (unsigned)framesetInfo.frameCount, name, reason);
}
#endif
void Screen::showFrame(FrameDirection direction)
{
// Only advance frames when UI is stable
if (ui->getUiState()->frameState == FIXED) {
#ifdef USERPREFS_UI_TEST_LOG
// Log the *intended* target before the (async) transition fires, so
// tests see a deterministic record of what was requested.
if (framesetInfo.frameCount > 0) {
uint8_t curr = ui->getUiState()->currentFrame;
uint8_t target = (direction == FrameDirection::NEXT)
? (uint8_t)((curr + 1) % framesetInfo.frameCount)
: (uint8_t)((curr + framesetInfo.frameCount - 1) % framesetInfo.frameCount);
logFrameChange(direction == FrameDirection::NEXT ? "next" : "prev", target);
}
#endif
if (direction == FrameDirection::NEXT) {
ui->nextFrame();
} else {
ui->previousFrame();
}
lastScreenTransition = millis();
setFastFramerate();
}
}
#ifndef SCREEN_TRANSITION_FRAMERATE
#define SCREEN_TRANSITION_FRAMERATE 30 // fps
#endif
void Screen::setFastFramerate()
{
#if defined(OLED_TINY)
dispdev->clear();
#if GRAPHICS_TFT_COLORING_ENABLED
prepareFrameColorRegions();
#endif
dispdev->display();
#endif
// We are about to start a transition so speed up fps
targetFramerate = SCREEN_TRANSITION_FRAMERATE;
ui->setTargetFPS(targetFramerate);
setInterval(0); // redraw ASAP
runASAP = true;
}
int Screen::handleStatusUpdate(const meshtastic::Status *arg)
{
switch (arg->getStatusType()) {
case STATUS_TYPE_NODE:
if (showingNormalScreen && nodeStatus->getLastNumTotal() != nodeStatus->getNumTotal()) {
setFrames(FOCUS_PRESERVE); // Regen the list of screen frames (returning to same frame, if possible)
}
nodeDB->updateGUI = false;
break;
case STATUS_TYPE_POWER: {
bool currentUSB = powerStatus->getHasUSB();
if (currentUSB != lastPowerUSBState) {
lastPowerUSBState = currentUSB;
forceDisplay(true);
}
break;
}
}
return 0;
}
// Triggered by MeshModules
int Screen::handleUIFrameEvent(const UIFrameEvent *event)
{
// Block UI frame events when virtual keyboard is active
if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) {
return 0;
}
if (showingNormalScreen) {
// Regenerate the frameset, potentially honoring a module's internal requestFocus() call
if (event->action == UIFrameEvent::Action::REGENERATE_FRAMESET) {
setFrames(FOCUS_MODULE);
}
// Regenerate the frameset, while attempting to maintain focus on the current frame
else if (event->action == UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND) {
setFrames(FOCUS_PRESERVE);
}
// Don't regenerate the frameset, just re-draw whatever is on screen ASAP
else if (event->action == UIFrameEvent::Action::REDRAW_ONLY) {
setFastFramerate();
}
// Jump directly to the Text Message screen
else if (event->action == UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE) {
setFrames(FOCUS_PRESERVE); // preserve current frame ordering
ui->switchToFrame(framesetInfo.positions.textMessage);
setFastFramerate(); // force redraw ASAP
}
}
return 0;
}
int Screen::handleInputEvent(const InputEvent *event)
{
LOG_INPUT("Screen Input event %u! kb %u", event->inputEvent, event->kbchar);
if (!screenOn)
return 0;
// Handle text input notifications specially - pass input to virtual keyboard
if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) {
NotificationRenderer::inEvent = *event;
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, 2);
setFastFramerate(); // Draw ASAP
updateUiFrame(ui);
return 0;
}
#ifdef USE_EINK // the screen is the last input handler, so if an event makes it here, we can assume it will prompt a screen draw.
EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please
EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update
handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?)
setFastFramerate(); // Draw ASAP
#endif
if (NotificationRenderer::isOverlayBannerShowing()) {
NotificationRenderer::inEvent = *event;
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, 2);
setFastFramerate(); // Draw ASAP
updateUiFrame(ui);
menuHandler::handleMenuSwitch(dispdev);
return 0;
}
// UP/DOWN in message screen scrolls through message threads
if (ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) {
if (event->inputEvent == INPUT_BROKER_UP) {
if (messageStore.getMessages().empty()) {
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
} else {
graphics::MessageRenderer::scrollUp();
setFastFramerate(); // match existing behavior
return 0;
}
}
if (event->inputEvent == INPUT_BROKER_DOWN) {
if (messageStore.getMessages().empty()) {
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
} else {
graphics::MessageRenderer::scrollDown();
setFastFramerate();
return 0;
}
}
}
// UP/DOWN in node list screens scrolls through node pages
if (ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_nodes ||
ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_location ||
ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_lastheard ||
ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_hopsignal ||
ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_distance ||
ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_bearings) {
if (event->inputEvent == INPUT_BROKER_UP) {
graphics::NodeListRenderer::scrollUp();
setFastFramerate();
return 0;
}
if (event->inputEvent == INPUT_BROKER_DOWN) {
graphics::NodeListRenderer::scrollDown();
setFastFramerate();
return 0;
}
}
// Use left or right input from a keyboard to move between frames,
// so long as a mesh module isn't using these events for some other purpose
if (showingNormalScreen) {
// Ask any MeshModules if they're handling keyboard input right now
bool inputIntercepted = false;
for (MeshModule *module : moduleFrames) {
if (module && module->interceptingKeyboardInput())
inputIntercepted = true;
}
// If no modules are using the input, move between frames
if (!inputIntercepted) {
#if defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2
bool handledEncoderScroll = false;
const bool isTextMessageFrame = (framesetInfo.positions.textMessage != 255 &&
this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage &&
!messageStore.getMessages().empty());
if (isTextMessageFrame) {
if (event->inputEvent == INPUT_BROKER_UP_LONG) {
graphics::MessageRenderer::nudgeScroll(-1);
handledEncoderScroll = true;
} else if (event->inputEvent == INPUT_BROKER_DOWN_LONG) {
graphics::MessageRenderer::nudgeScroll(1);
handledEncoderScroll = true;
}
}
if (handledEncoderScroll) {
setFastFramerate();
return 0;
}
#endif
if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_ALT_PRESS) {
showFrame(FrameDirection::PREVIOUS);
} else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS) {
showFrame(FrameDirection::NEXT);
} else if (event->inputEvent == INPUT_BROKER_FN_F1) {
this->ui->switchToFrame(0);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f1", 0);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F2) {
this->ui->switchToFrame(1);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f2", 1);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F3) {
this->ui->switchToFrame(2);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f3", 2);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F4) {
this->ui->switchToFrame(3);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f4", 3);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F5) {
this->ui->switchToFrame(4);
#ifdef USERPREFS_UI_TEST_LOG
logFrameChange("fn_f5", 4);
#endif
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_UP_LONG) {
// Long press up button for fast frame switching
showPrevFrame();
} else if (event->inputEvent == INPUT_BROKER_DOWN_LONG) {
// Long press down button for fast frame switching
showNextFrame();
} else if ((event->inputEvent == INPUT_BROKER_UP || event->inputEvent == INPUT_BROKER_DOWN) &&
this->ui->getUiState()->currentFrame == framesetInfo.positions.home) {
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST);
} else if (event->inputEvent == INPUT_BROKER_SELECT) {
if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) {
menuHandler::homeBaseMenu();
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.system) {
menuHandler::systemBaseMenu();
#if HAS_GPS
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.gps && gps) {
menuHandler::positionBaseMenu();
#endif
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.clock) {
menuHandler::clockMenu();
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) {
menuHandler::loraMenu();
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) {
if (!messageStore.getMessages().empty()) {
menuHandler::messageResponseMenu();
} else {
if (currentResolution == ScreenResolution::UltraLow) {
menuHandler::textMessageMenu();
} else {
menuHandler::textMessageBaseMenu();
}
}
} else if (framesetInfo.positions.firstFavorite != 255 &&
this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite &&
this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) {
menuHandler::favoriteBaseMenu();
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_nodes ||
this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_location ||
this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_lastheard ||
this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_hopsignal ||
this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_distance ||
this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_hopsignal ||
this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_bearings) {
menuHandler::nodeListMenu();
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.wifi) {
menuHandler::wifiBaseMenu();
}
} else if (event->inputEvent == INPUT_BROKER_BACK) {
showFrame(FrameDirection::PREVIOUS);
} else if (event->inputEvent == INPUT_BROKER_CANCEL) {
setOn(false);
}
}
}
return 0;
}
int Screen::handleAdminMessage(AdminModule_ObserverData *arg)
{
switch (arg->request->which_payload_variant) {
// Node removed manually (i.e. via app)
case meshtastic_AdminMessage_remove_by_nodenum_tag:
setFrames(FOCUS_PRESERVE);
*arg->result = AdminMessageHandleResult::HANDLED;
break;
// Default no-op, in case the admin message observable gets used by other classes in future
default:
break;
}
return 0;
}
bool Screen::isOverlayBannerShowing()
{
return NotificationRenderer::isOverlayBannerShowing();
}
} // namespace graphics
#else
graphics::Screen::Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY) {}
#endif // HAS_SCREEN
bool shouldWakeOnReceivedMessage()
{
/*
The goal here is to determine when we do NOT wake up the screen on message received:
- Any ext. notifications are turned on
- If role is not CLIENT / CLIENT_MUTE / CLIENT_HIDDEN / CLIENT_BASE
- If the battery level is very low
*/
if (moduleConfig.external_notification.enabled) {
return false;
}
if (!IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_CLIENT,
meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE, meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN,
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
return false;
}
if (powerStatus && powerStatus->getBatteryChargePercent() < 10) {
return false;
}
return true;
}