/* 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 . */ #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 #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 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 // 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 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 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 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(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(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(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions); #elif defined(USE_ST7796) static_cast(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(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(dispdev)->setDetected(model); #endif #if defined(USE_SH1107_128_64) || defined(USE_SH1107) static_cast(dispdev)->setSubtype(7); #endif #if defined(USE_ST7789) static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion), "graphics::TFTColorRegion layout must match ST7789 TFTColorRegion"); static_cast(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions); #endif #if defined(MUZI_BASE) dispdev->delayPoweron = true; #endif #if defined(USE_ST7796) static_cast(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(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(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(dispdev)->flipScreenVertically(); #elif defined(USE_ST7789) static_cast(dispdev)->flipScreenVertically(); #elif defined(USE_ST7796) static_cast(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(dispdev)->getTouch); touchScreenImpl1->init(); } } #elif HAS_TOUCHSCREEN && !defined(USE_EINK) && !VARIANT_TOUCHSCREEN touchScreenImpl1 = new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(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(dispdev)->forceDisplay(); #elif defined(USE_EINK) static_cast(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(dispdev)->forceDisplay(0); #elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) // Old EInkDisplay class static_cast(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 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(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(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; }