/* 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 "PowerMon.h" #include "Throttle.h" #include "configuration.h" #if HAS_SCREEN #include #include "DisplayFormatters.h" #include "draw/DebugRenderer.h" #include "draw/MessageRenderer.h" #include "draw/NodeListRenderer.h" #include "draw/NotificationRenderer.h" #include "draw/UIRenderer.h" #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" #endif #include "FSCommon.h" #include "MeshService.h" #include "NodeDB.h" #include "RadioLibInterface.h" #include "error.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/emotes.h" #include "graphics/images.h" #include "input/ButtonThread.h" #include "input/ScanAndSelect.h" #include "input/TouchScreenImpl1.h" #include "main.h" #include "mesh-pb-constants.h" #include "mesh/Channels.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" #include "meshUtils.h" #include "modules/AdminModule.h" #include "modules/ExternalNotificationModule.h" #include "modules/TextMessageModule.h" #include "modules/WaypointModule.h" #include "sleep.h" #include "target_specific.h" using graphics::Emote; using graphics::emotes; using graphics::numEmotes; #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 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 // DEBUG #define NUM_EXTRA_FRAMES 3 // text message and debug frame // if defined a pixel will blink to show redraws // #define SHOW_REDRAWS // A text message frame + debug frame + all the node infos FrameCallback *normalFrames; static uint32_t targetFramerate = IDLE_FRAMERATE; // Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization uint32_t logo_timeout = 5000; // 4 seconds for EACH logo // This image definition is here instead of images.h because it's modified dynamically by the drawBattery function uint8_t imgBattery[16] = {0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xE7, 0x3C}; // 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; // Global variables for screen function overlay symbols std::vector functionSymbol; std::string functionSymbolString; #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; // ============================== // 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 // Called to trigger a banner with custom message and duration void Screen::showOverlayBanner(const char *message, uint32_t durationMs, uint8_t options, std::function bannerCallback) { // 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::alertBannerOptions = options; NotificationRenderer::alertBannerCallback = bannerCallback; NotificationRenderer::curSelected = 0; } 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; // LOG_DEBUG("Screen is not in transition. Frame: %d", module_frame); } // LOG_DEBUG("Draw Module Frame %d", module_frame); MeshModule &pi = *moduleFrames.at(module_frame); pi.drawFrame(display, state, x, y); } // Ignore messages originating from phone (from the current node 0x0) unless range test or store and forward module are enabled static bool shouldDrawMessage(const meshtastic_MeshPacket *packet) { return packet->from != 0 && !moduleConfig.store_forward.enabled; } #if defined(DISPLAY_CLOCK_FRAME) void Screen::drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode, float scale) { uint16_t segmentWidth = SEGMENT_WIDTH * scale; uint16_t segmentHeight = SEGMENT_HEIGHT * scale; if (digitalMode) { uint16_t radius = (segmentWidth + (segmentHeight * 2) + 4) / 2; uint16_t centerX = (x + segmentHeight + 2) + (radius / 2); uint16_t centerY = (y + segmentHeight + 2) + (radius / 2); display->drawCircle(centerX, centerY, radius); display->drawCircle(centerX, centerY, radius + 1); display->drawLine(centerX, centerY, centerX, centerY - radius + 3); display->drawLine(centerX, centerY, centerX + radius - 3, centerY); } else { uint16_t segmentOneX = x + segmentHeight + 2; uint16_t segmentOneY = y; uint16_t segmentTwoX = segmentOneX + segmentWidth + 2; uint16_t segmentTwoY = segmentOneY + segmentHeight + 2; uint16_t segmentThreeX = segmentOneX; uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2; uint16_t segmentFourX = x; uint16_t segmentFourY = y + segmentHeight + 2; drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight); drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight); drawHorizontalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight); drawVerticalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight); } } // Draw a digital clock void Screen::drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setTextAlignment(TEXT_ALIGN_LEFT); UIRenderer::drawBattery(display, x, y + 7, imgBattery, powerStatus); if (powerStatus->getHasBattery()) { char batteryPercent[8]; snprintf(batteryPercent, sizeof(batteryPercent), "%d%%", powerStatus->getBatteryChargePercent()); display->setFont(FONT_SMALL); display->drawString(x + 20, y + 2, batteryPercent); } if (nimbleBluetooth && nimbleBluetooth->isConnected()) { drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2); } drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36, screen->digitalWatchFace, 1); display->setColor(OLEDDISPLAY_COLOR::WHITE); uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone if (rtc_sec > 0) { long hms = rtc_sec % SEC_PER_DAY; hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; int hour = hms / SEC_PER_HOUR; int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; int second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN hour = hour > 12 ? hour - 12 : hour; if (hour == 0) { hour = 12; } // Format time string char timeString[16]; snprintf(timeString, sizeof(timeString), "%d:%02d", hour, minute); // Format seconds string char secondString[8]; snprintf(secondString, sizeof(secondString), "%02d", second); float scale = 1.5; uint16_t segmentWidth = SEGMENT_WIDTH * scale; uint16_t segmentHeight = SEGMENT_HEIGHT * scale; // calculate hours:minutes string width uint16_t timeStringWidth = strlen(timeString) * 5; for (uint8_t i = 0; i < strlen(timeString); i++) { char character = timeString[i]; if (character == ':') { timeStringWidth += segmentHeight; } else { timeStringWidth += segmentWidth + (segmentHeight * 2) + 4; } } // calculate seconds string width uint16_t secondStringWidth = (strlen(secondString) * 12) + 4; // sum these to get total string width uint16_t totalWidth = timeStringWidth + secondStringWidth; uint16_t hourMinuteTextX = (display->getWidth() / 2) - (totalWidth / 2); uint16_t startingHourMinuteTextX = hourMinuteTextX; uint16_t hourMinuteTextY = (display->getHeight() / 2) - (((segmentWidth * 2) + (segmentHeight * 3) + 8) / 2); // iterate over characters in hours:minutes string and draw segmented characters for (uint8_t i = 0; i < strlen(timeString); i++) { char character = timeString[i]; if (character == ':') { drawSegmentedDisplayColon(display, hourMinuteTextX, hourMinuteTextY, scale); hourMinuteTextX += segmentHeight + 6; } else { drawSegmentedDisplayCharacter(display, hourMinuteTextX, hourMinuteTextY, character - '0', scale); hourMinuteTextX += segmentWidth + (segmentHeight * 2) + 4; } hourMinuteTextX += 5; } // draw seconds string display->setFont(FONT_MEDIUM); display->drawString(startingHourMinuteTextX + timeStringWidth + 4, (display->getHeight() - hourMinuteTextY) - FONT_HEIGHT_MEDIUM + 6, secondString); } } void Screen::drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale) { uint16_t segmentWidth = SEGMENT_WIDTH * scale; uint16_t segmentHeight = SEGMENT_HEIGHT * scale; uint16_t cellHeight = (segmentWidth * 2) + (segmentHeight * 3) + 8; uint16_t topAndBottomX = x + (4 * scale); uint16_t quarterCellHeight = cellHeight / 4; uint16_t topY = y + quarterCellHeight; uint16_t bottomY = y + (quarterCellHeight * 3); display->fillRect(topAndBottomX, topY, segmentHeight, segmentHeight); display->fillRect(topAndBottomX, bottomY, segmentHeight, segmentHeight); } void Screen::drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale) { // the numbers 0-9, each expressed as an array of seven boolean (0|1) values encoding the on/off state of // segment {innerIndex + 1} // e.g., to display the numeral '0', segments 1-6 are on, and segment 7 is off. uint8_t numbers[10][7] = { {1, 1, 1, 1, 1, 1, 0}, // 0 Display segment key {0, 1, 1, 0, 0, 0, 0}, // 1 1 {1, 1, 0, 1, 1, 0, 1}, // 2 ___ {1, 1, 1, 1, 0, 0, 1}, // 3 6 | | 2 {0, 1, 1, 0, 0, 1, 1}, // 4 |_7̲_| {1, 0, 1, 1, 0, 1, 1}, // 5 5 | | 3 {1, 0, 1, 1, 1, 1, 1}, // 6 |___| {1, 1, 1, 0, 0, 1, 0}, // 7 {1, 1, 1, 1, 1, 1, 1}, // 8 4 {1, 1, 1, 1, 0, 1, 1}, // 9 }; // the width and height of each segment's central rectangle: // _____________________ // ⋰| (only this part, |⋱ // ⋰ | not including | ⋱ // ⋱ | the triangles | ⋰ // ⋱| on the ends) |⋰ // ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ uint16_t segmentWidth = SEGMENT_WIDTH * scale; uint16_t segmentHeight = SEGMENT_HEIGHT * scale; // segment x and y coordinates uint16_t segmentOneX = x + segmentHeight + 2; uint16_t segmentOneY = y; uint16_t segmentTwoX = segmentOneX + segmentWidth + 2; uint16_t segmentTwoY = segmentOneY + segmentHeight + 2; uint16_t segmentThreeX = segmentTwoX; uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2 + segmentHeight + 2; uint16_t segmentFourX = segmentOneX; uint16_t segmentFourY = segmentThreeY + segmentWidth + 2; uint16_t segmentFiveX = x; uint16_t segmentFiveY = segmentThreeY; uint16_t segmentSixX = x; uint16_t segmentSixY = segmentTwoY; uint16_t segmentSevenX = segmentOneX; uint16_t segmentSevenY = segmentTwoY + segmentWidth + 2; if (numbers[number][0]) { drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight); } if (numbers[number][1]) { drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight); } if (numbers[number][2]) { drawVerticalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight); } if (numbers[number][3]) { drawHorizontalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight); } if (numbers[number][4]) { drawVerticalSegment(display, segmentFiveX, segmentFiveY, segmentWidth, segmentHeight); } if (numbers[number][5]) { drawVerticalSegment(display, segmentSixX, segmentSixY, segmentWidth, segmentHeight); } if (numbers[number][6]) { drawHorizontalSegment(display, segmentSevenX, segmentSevenY, segmentWidth, segmentHeight); } } void Screen::drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height) { int halfHeight = height / 2; // draw central rectangle display->fillRect(x, y, width, height); // draw end triangles display->fillTriangle(x, y, x, y + height - 1, x - halfHeight, y + halfHeight); display->fillTriangle(x + width, y, x + width + halfHeight, y + halfHeight, x + width, y + height - 1); } void Screen::drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height) { int halfHeight = height / 2; // draw central rectangle display->fillRect(x, y, height, width); // draw end triangles display->fillTriangle(x + halfHeight, y - halfHeight, x + height - 1, y, x, y); display->fillTriangle(x, y + width, x + height - 1, y + width, x + halfHeight, y + width + halfHeight); } void Screen::drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y) { display->drawFastImage(x, y, 18, 14, bluetoothConnectedIcon); } // Draw an analog clock void Screen::drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setTextAlignment(TEXT_ALIGN_LEFT); UIRenderer::drawBattery(display, x, y + 7, imgBattery, powerStatus); if (powerStatus->getHasBattery()) { char batteryPercent[8]; snprintf(batteryPercent, sizeof(batteryPercent), "%d%%", powerStatus->getBatteryChargePercent()); display->setFont(FONT_SMALL); display->drawString(x + 20, y + 2, batteryPercent); } if (nimbleBluetooth && nimbleBluetooth->isConnected()) { drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2); } drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36, screen->digitalWatchFace, 1); // clock face center coordinates int16_t centerX = display->getWidth() / 2; int16_t centerY = display->getHeight() / 2; // clock face radius int16_t radius = (display->getWidth() / 2) * 0.8; // noon (0 deg) coordinates (outermost circle) int16_t noonX = centerX; int16_t noonY = centerY - radius; // second hand radius and y coordinate (outermost circle) int16_t secondHandNoonY = noonY + 1; // tick mark outer y coordinate; (first nested circle) int16_t tickMarkOuterNoonY = secondHandNoonY; // seconds tick mark inner y coordinate; (second nested circle) double secondsTickMarkInnerNoonY = (double)noonY + 8; // hours tick mark inner y coordinate; (third nested circle) double hoursTickMarkInnerNoonY = (double)noonY + 16; // minute hand y coordinate int16_t minuteHandNoonY = secondsTickMarkInnerNoonY + 4; // hour string y coordinate int16_t hourStringNoonY = minuteHandNoonY + 18; // hour hand radius and y coordinate int16_t hourHandRadius = radius * 0.55; int16_t hourHandNoonY = centerY - hourHandRadius; display->setColor(OLEDDISPLAY_COLOR::WHITE); display->drawCircle(centerX, centerY, radius); uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone if (rtc_sec > 0) { long hms = rtc_sec % SEC_PER_DAY; hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; // Tear apart hms into h:m:s int hour = hms / SEC_PER_HOUR; int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; int second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN hour = hour > 12 ? hour - 12 : hour; int16_t degreesPerHour = 30; int16_t degreesPerMinuteOrSecond = 6; double hourBaseAngle = hour * degreesPerHour; double hourAngleOffset = ((double)minute / 60) * degreesPerHour; double hourAngle = radians(hourBaseAngle + hourAngleOffset); double minuteBaseAngle = minute * degreesPerMinuteOrSecond; double minuteAngleOffset = ((double)second / 60) * degreesPerMinuteOrSecond; double minuteAngle = radians(minuteBaseAngle + minuteAngleOffset); double secondAngle = radians(second * degreesPerMinuteOrSecond); double hourX = sin(-hourAngle) * (hourHandNoonY - centerY) + noonX; double hourY = cos(-hourAngle) * (hourHandNoonY - centerY) + centerY; double minuteX = sin(-minuteAngle) * (minuteHandNoonY - centerY) + noonX; double minuteY = cos(-minuteAngle) * (minuteHandNoonY - centerY) + centerY; double secondX = sin(-secondAngle) * (secondHandNoonY - centerY) + noonX; double secondY = cos(-secondAngle) * (secondHandNoonY - centerY) + centerY; display->setFont(FONT_MEDIUM); // draw minute and hour tick marks and hour numbers for (uint16_t angle = 0; angle < 360; angle += 6) { double angleInRadians = radians(angle); double sineAngleInRadians = sin(-angleInRadians); double cosineAngleInRadians = cos(-angleInRadians); double endX = sineAngleInRadians * (tickMarkOuterNoonY - centerY) + noonX; double endY = cosineAngleInRadians * (tickMarkOuterNoonY - centerY) + centerY; if (angle % degreesPerHour == 0) { double startX = sineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + noonX; double startY = cosineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + centerY; // draw hour tick mark display->drawLine(startX, startY, endX, endY); static char buffer[2]; uint8_t hourInt = (angle / 30); if (hourInt == 0) { hourInt = 12; } // hour number x offset needs to be adjusted for some cases int8_t hourStringXOffset; int8_t hourStringYOffset = 13; switch (hourInt) { case 3: hourStringXOffset = 5; break; case 9: hourStringXOffset = 7; break; case 10: case 11: hourStringXOffset = 8; break; case 12: hourStringXOffset = 13; break; default: hourStringXOffset = 6; break; } double hourStringX = (sineAngleInRadians * (hourStringNoonY - centerY) + noonX) - hourStringXOffset; double hourStringY = (cosineAngleInRadians * (hourStringNoonY - centerY) + centerY) - hourStringYOffset; // draw hour number display->drawStringf(hourStringX, hourStringY, buffer, "%d", hourInt); } if (angle % degreesPerMinuteOrSecond == 0) { double startX = sineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + noonX; double startY = cosineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + centerY; // draw minute tick mark display->drawLine(startX, startY, endX, endY); } } // draw hour hand display->drawLine(centerX, centerY, hourX, hourY); // draw minute hand display->drawLine(centerX, centerY, minuteX, minuteY); // draw second hand display->drawLine(centerX, centerY, secondX, secondY); } } #endif // Get an absolute time from "seconds ago" info. Returns false if no valid timestamp possible bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int32_t *daysAgo) { // Cache the result - avoid frequent recalculation static uint8_t hoursCached = 0, minutesCached = 0; static uint32_t daysAgoCached = 0; static uint32_t secondsAgoCached = 0; static bool validCached = false; // Abort: if timezone not set if (strlen(config.device.tzdef) == 0) { validCached = false; return validCached; } // Abort: if invalid pointers passed if (hours == nullptr || minutes == nullptr || daysAgo == nullptr) { validCached = false; return validCached; } // Abort: if time seems invalid.. (> 6 months ago, probably seen before RTC set) if (secondsAgo > SEC_PER_DAY * 30UL * 6) { validCached = false; return validCached; } // If repeated request, don't bother recalculating if (secondsAgo - secondsAgoCached < 60 && secondsAgoCached != 0) { if (validCached) { *hours = hoursCached; *minutes = minutesCached; *daysAgo = daysAgoCached; } return validCached; } // Get local time uint32_t secondsRTC = getValidTime(RTCQuality::RTCQualityDevice, true); // Get local time // Abort: if RTC not set if (!secondsRTC) { validCached = false; return validCached; } // Get absolute time when last seen uint32_t secondsSeenAt = secondsRTC - secondsAgo; // Calculate daysAgo *daysAgo = (secondsRTC / SEC_PER_DAY) - (secondsSeenAt / SEC_PER_DAY); // How many "midnights" have passed // Get seconds since midnight uint32_t hms = (secondsRTC - secondsAgo) % SEC_PER_DAY; hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; // Tear apart hms into hours and minutes *hours = hms / SEC_PER_HOUR; *minutes = (hms % SEC_PER_HOUR) / SEC_PER_MIN; // Cache the result daysAgoCached = *daysAgo; hoursCached = *hours; minutesCached = *minutes; secondsAgoCached = secondsAgo; validCached = true; return validCached; } /** * 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; if (oldLat == 0) { // just prepare for next time oldLat = lat; oldLon = lon; return b; } float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon); if (d < 10) // haven't moved enough, just keep current bearing return b; b = GeoCoord::bearing(oldLat, oldLon, lat, lon); oldLat = lat; oldLon = lon; return b; } /// We will skip one node - the one for us, so we just blindly loop over all /// nodes static int8_t prevFrame = -1; // Get a string representation of the time passed since something happened void Screen::getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength) { // Use an absolute timestamp in some cases. // Particularly useful with E-Ink displays. Static UI, fewer refreshes. uint8_t timestampHours, timestampMinutes; int32_t daysAgo; bool useTimestamp = deltaToTimestamp(agoSecs, ×tampHours, ×tampMinutes, &daysAgo); if (agoSecs < 120) // last 2 mins? snprintf(timeStr, maxLength, "%u seconds ago", agoSecs); // -- if suitable for timestamp -- else if (useTimestamp && agoSecs < 15 * SECONDS_IN_MINUTE) // Last 15 minutes snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / SECONDS_IN_MINUTE); else if (useTimestamp && daysAgo == 0) // Today snprintf(timeStr, maxLength, "Last seen: %02u:%02u", (unsigned int)timestampHours, (unsigned int)timestampMinutes); else if (useTimestamp && daysAgo == 1) // Yesterday snprintf(timeStr, maxLength, "Seen yesterday"); else if (useTimestamp && daysAgo > 1) // Last six months (capped by deltaToTimestamp method) snprintf(timeStr, maxLength, "%li days ago", (long)daysAgo); // -- if using time delta instead -- else if (agoSecs < 120 * 60) // last 2 hrs snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / 60); // Only show hours ago if it's been less than 6 months. Otherwise, we may have bad data. else if ((agoSecs / 60 / 60) < (HOURS_IN_MONTH * 6)) snprintf(timeStr, maxLength, "%u hours ago", agoSecs / 60 / 60); else snprintf(timeStr, maxLength, "unknown age"); } // 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) 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); static_cast(dispdev)->setRGB(COLOR565(255, 255, 128)); #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); #elif defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7789_CS) || \ defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) 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) 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_ST7567) dispdev = new ST7567Wire(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); #elif ARCH_PORTDUINO if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { if (settingsMap[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; } } #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 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); #ifdef PIN_EINK_EN digitalWrite(PIN_EINK_EN, LOW); // power off backlight #endif #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"); buttonThread->setScreenFlag(true); powerMon->setState(meshtastic_PowerMon_State_Screen_On); #ifdef T_WATCH_S3 PMU->enablePowerOutput(XPOWERS_ALDO2); #endif #ifdef HELTEC_TRACKER_V1_X uint8_t tft_vext_enabled = digitalRead(VEXT_ENABLE); #endif #if !ARCH_PORTDUINO dispdev->displayOn(); #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(); #ifdef HELTEC_TRACKER_V1_X // If the TFT VEXT power is not enabled, initialize the UI. if (!tft_vext_enabled) { ui->init(); } #endif #ifdef USE_ST7789 pinMode(VTFT_CTRL, OUTPUT); digitalWrite(VTFT_CTRL, LOW); 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 LOG_INFO("Turn off screen"); buttonThread->setScreenFlag(false); #ifdef ELECROW_ThinkNode_M1 if (digitalRead(PIN_EINK_EN) == HIGH) { digitalWrite(PIN_EINK_EN, LOW); } #endif dispdev->displayOff(); #ifdef USE_ST7789 SPI1.end(); #if defined(ARCH_ESP32) pinMode(VTFT_LEDA, ANALOG); pinMode(VTFT_CTRL, ANALOG); pinMode(ST7789_RESET, ANALOG); pinMode(ST7789_RS, ANALOG); pinMode(ST7789_NSS, ANALOG); #else nrf_gpio_cfg_default(VTFT_LEDA); nrf_gpio_cfg_default(VTFT_CTRL); nrf_gpio_cfg_default(ST7789_RESET); nrf_gpio_cfg_default(ST7789_RS); nrf_gpio_cfg_default(ST7789_NSS); #endif #endif #ifdef T_WATCH_S3 PMU->disablePowerOutput(XPOWERS_ALDO2); #endif enabled = false; } screenOn = on; } } void Screen::setup() { // === Enable display rendering === useDisplay = true; // === Detect OLED subtype (if supported by board variant) === #ifdef AutoOLEDWire_h if (isAUTOOled) static_cast(dispdev)->setDetected(model); #endif #ifdef USE_SH1107_128_64 static_cast(dispdev)->setSubtype(7); #endif #if defined(USE_ST7789) && defined(TFT_MESH) // Apply custom RGB color (e.g. Heltec T114/T190) static_cast(dispdev)->setRGB(TFT_MESH); #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 // === Set custom overlay callbacks === static OverlayCallback overlays[] = { graphics::UIRenderer::drawFunctionOverlay, // For mute/buzzer modifiers etc. graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame }; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); // === 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::drawIconScreen(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 defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \ defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) static_cast(dispdev)->flipScreenVertically(); #elif defined(USE_ST7789) static_cast(dispdev)->flipScreenVertically(); #else 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); ui->update(); #ifndef USE_EINK ui->update(); // 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 (settingsMap[touchscreenModule]) { touchScreenImpl1 = new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch); touchScreenImpl1->init(); } } #elif HAS_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 (textMessageModule) textMessageObserver.observe(textMessageModule); if (inputBroker) inputObserver.observe(inputBroker); // === Notify modules that support UI events === MeshModule::observeUIEvents(&uiFrameEventObserver); } 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); ui->update(); } while (ui->getUiState()->lastUpdate < startUpdate); // Return to normal frame rate targetFramerate = IDLE_FRAMERATE; ui->setTargetFPS(targetFramerate); } // Tell EInk class to update the display static_cast(dispdev)->forceDisplay(); #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(); } // 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); ui->update(); #ifndef USE_EINK ui->update(); #endif showingOEMBootScreen = false; } #endif #ifndef DISABLE_WELCOME_UNSET if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { showOverlayBanner( "Set the LoRa " "region\nUS\nEU_433\nEU_868\nCN\nJP\nANZ\nKR\nTW\nRU\nIN\nNZ_865\nTH\nLORA_24\nUA_433\nUA_868\nMY_433\nMY_919\nSG_" "923\nPH_433\nPH_868\nPH_915", 0, 21, [](int selected) -> void { LOG_WARN("Chose %d", selected); config.lora.region = _meshtastic_Config_LoRaConfig_RegionCode(selected + 1); if (!owner.is_licensed) { bool keygenSuccess = false; if (config.security.private_key.size == 32) { if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { keygenSuccess = true; } } else { LOG_INFO("Generate new PKI keys"); crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); keygenSuccess = true; } if (keygenSuccess) { config.security.public_key.size = 32; config.security.private_key.size = 32; owner.public_key.size = 32; memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); } } config.lora.tx_enabled = true; initRegion(); if (myRegion->dutyCycle < 100) { config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit } service->reloadConfig(SEGMENT_CONFIG); rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }); } #endif if (!NotificationRenderer::isOverlayBannerShowing() && rebootAtMsec != 0) { showOverlayBanner("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: handleOnPress(); break; case Cmd::SHOW_PREV_FRAME: handleShowPrevFrame(); break; case Cmd::SHOW_NEXT_FRAME: handleShowNextFrame(); 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; 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: case Cmd::STOP_BOOT_SCREEN: EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame setFrames(); break; case Cmd::PRINT: handlePrint(cmd.print_text); free(cmd.print_text); 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 ui->update(); // 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. if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) { // oldFrameState = ui->getUiState()->frameState; targetFramerate = IDLE_FRAMERATE; 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 && !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); ui->update(); } } #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); ui->update(); } while (ui->getUiState()->lastUpdate < startUpdate); // Old EInkDisplay class #if !defined(USE_EINK_DYNAMICDISPLAY) 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) { uint8_t originalPosition = ui->getUiState()->currentFrame; FramesetInfo fsi; // Location of specific frames, for applying focus parameter LOG_DEBUG("Show standard frames"); showingNormalScreen = true; indicatorIcons.clear(); #ifdef USE_EINK // If user has disabled the screensaver, warn them after boot static bool warnedScreensaverDisabled = false; if (config.display.screen_on_secs == 0 && !warnedScreensaverDisabled) { screen->print("Screensaver disabled\n"); warnedScreensaverDisabled = true; } #endif moduleFrames = MeshModule::GetMeshModulesWithUIFrames(); LOG_DEBUG("Show %d module frames", moduleFrames.size()); #ifdef DEBUG_PORT int totalFrameCount = MAX_NUM_NODES + NUM_EXTRA_FRAMES + moduleFrames.size(); LOG_DEBUG("Total frame count: %d", totalFrameCount); #endif // 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--; size_t numframes = 0; // put all of the module frames first. // this is a little bit of a dirty hack; since we're going to call // the same drawModuleFrame handler here for all of these module frames // and then we'll just assume that the state->currentFrame value // is the same offset into the moduleFrames vector // so that we can invoke the module's callback for (auto i = moduleFrames.begin(); i != moduleFrames.end(); ++i) { // Draw the module frame, using the hack described above 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->isRequestingFocus()) fsi.positions.focusedModule = numframes; if (m == waypointModule) fsi.positions.waypoint = numframes; indicatorIcons.push_back(icon_module); numframes++; } LOG_DEBUG("Added modules. numframes: %d", numframes); // 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) normalFrames[numframes++] = screen->digitalWatchFace ? &Screen::drawDigitalClockFrame : &Screen::drawAnalogClockFrame; indicatorIcons.push_back(icon_compass); #endif // Declare this early so it’s available in FOCUS_PRESERVE block bool willInsertTextMessage = shouldDrawMessage(&devicestate.rx_text_message); if (willInsertTextMessage) { fsi.positions.textMessage = numframes; normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame; indicatorIcons.push_back(icon_mail); } normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused; indicatorIcons.push_back(icon_home); #ifndef USE_EINK normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicNodeListScreen; indicatorIcons.push_back(icon_nodes); #endif // Show detailed node views only on E-Ink builds #ifdef USE_EINK normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen; indicatorIcons.push_back(icon_nodes); normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen; indicatorIcons.push_back(icon_signal); normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen; indicatorIcons.push_back(icon_distance); #endif normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; indicatorIcons.push_back(icon_list); fsi.positions.gps = numframes; normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen; indicatorIcons.push_back(icon_compass); normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused; indicatorIcons.push_back(icon_radio); if (!dismissedFrames.memory) { fsi.positions.memory = numframes; normalFrames[numframes++] = graphics::DebugRenderer::drawMemoryUsage; indicatorIcons.push_back(icon_memory); } for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) { normalFrames[numframes++] = graphics::UIRenderer::drawNodeInfo; indicatorIcons.push_back(icon_node); } } // then the debug info // Since frames are basic function pointers, we have to use a helper to // call a method on debugInfo object. // fsi.positions.log = numframes; // normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoTrampoline; // call a method on debugInfoScreen object (for more details) // fsi.positions.settings = numframes; // normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoSettingsTrampoline; #if HAS_WIFI && !defined(ARCH_PORTDUINO) if (!dismissedFrames.wifi && isWifiAvailable()) { fsi.positions.wifi = numframes; normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoWiFiTrampoline; indicatorIcons.push_back(icon_wifi); } #endif 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::drawAlertBannerOverlay}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); prevFrame = -1; // Force drawNodeInfo 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_TEXTMESSAGE: hasUnreadMessage = false; // ✅ Clear when message is *viewed* ui->switchToFrame(fsi.positions.textMessage); 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_PRESERVE: // No more adjustment — force stay on same index if (originalPosition < fsi.frameCount) ui->switchToFrame(originalPosition); else ui->switchToFrame(fsi.frameCount - 1); break; } // Store the info about this frameset, for future setFrames calls this->framesetInfo = fsi; setFastFramerate(); // Draw ASAP } void Screen::setFrameImmediateDraw(FrameCallback *drawFrames) { ui->disableAllIndicators(); ui->setFrames(drawFrames, 1); setFastFramerate(); } // Dismisses the currently displayed screen frame, if possible // Relevant for text message, waypoint, others in future? // Triggered with a CardKB keycombo void Screen::dismissCurrentFrame() { uint8_t currentFrame = ui->getUiState()->currentFrame; bool dismissed = false; if (currentFrame == framesetInfo.positions.textMessage && devicestate.has_rx_text_message) { LOG_INFO("Dismiss Text Message"); devicestate.has_rx_text_message = false; memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message)); dismissedFrames.textMessage = true; dismissed = true; } else if (currentFrame == framesetInfo.positions.waypoint && devicestate.has_rx_waypoint) { LOG_DEBUG("Dismiss Waypoint"); devicestate.has_rx_waypoint = false; dismissedFrames.waypoint = true; dismissed = true; } else if (currentFrame == framesetInfo.positions.wifi) { LOG_DEBUG("Dismiss WiFi Screen"); dismissedFrames.wifi = true; dismissed = true; } else if (currentFrame == framesetInfo.positions.memory) { LOG_INFO("Dismiss Memory"); dismissedFrames.memory = true; dismissed = true; } if (dismissed) { setFrames(FOCUS_DEFAULT); // You could also use FOCUS_PRESERVE } } 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() { setFastFramerate(); uint8_t count = 10; dispdev->setBrightness(254); while (count > 0) { dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight()); dispdev->display(); delay(50); dispdev->clear(); 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::setFunctionSymbol(std::string sym) { if (std::find(functionSymbol.begin(), functionSymbol.end(), sym) == functionSymbol.end()) { functionSymbol.push_back(sym); functionSymbolString = ""; for (auto symbol : functionSymbol) { functionSymbolString = symbol + " " + functionSymbolString; } setFastFramerate(); } } void Screen::removeFunctionSymbol(std::string sym) { functionSymbol.erase(std::remove(functionSymbol.begin(), functionSymbol.end(), sym), functionSymbol.end()); functionSymbolString = ""; for (auto symbol : functionSymbol) { functionSymbolString = symbol + " " + functionSymbolString; } setFastFramerate(); } void Screen::handlePrint(const char *text) { // the string passed into us probably has a newline, but that would confuse the logging system // so strip it LOG_DEBUG("Screen: %.*s", strlen(text) - 1, text); if (!useDisplay || !showingNormalScreen) return; dispdev->print(text); } void Screen::handleOnPress() { // If Canned Messages is using the "Scan and Select" input, dismiss the canned message frame when user button is pressed // Minimize impact as a courtesy, as "scan and select" may be used as default config for some boards if (scanAndSelectInput != nullptr && scanAndSelectInput->dismissCannedMessageFrame()) return; // 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(); } } void Screen::handleShowPrevFrame() { // If screen was off, just wake it, otherwise go back to previous frame // If we are in a transition, the press must have bounced, drop it. if (ui->getUiState()->frameState == FIXED) { ui->previousFrame(); lastScreenTransition = millis(); setFastFramerate(); } } void Screen::handleShowNextFrame() { // 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(); } } #ifndef SCREEN_TRANSITION_FRAMERATE #define SCREEN_TRANSITION_FRAMERATE 30 // fps #endif void Screen::setFastFramerate() { // 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) { // LOG_DEBUG("Screen got status update %d", arg->getStatusType()); 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; } return 0; } // Handles when message is received; will jump to text message frame. int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) { if (showingNormalScreen) { if (packet->from == 0) { // Outgoing message (likely sent from phone) devicestate.has_rx_text_message = false; memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message)); dismissedFrames.textMessage = true; hasUnreadMessage = false; // Clear unread state when user replies setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list } else { // Incoming message devicestate.has_rx_text_message = true; // Needed to include the message frame hasUnreadMessage = true; // Enables mail icon in the header setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view forceDisplay(); // Forces screen redraw // === Prepare banner content === const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from); const char *longName = (node && node->has_user) ? node->user.long_name : nullptr; const char *msgRaw = reinterpret_cast(packet->decoded.payload.bytes); char banner[256]; // Check for bell character in message to determine alert type bool isAlert = false; for (size_t i = 0; i < packet->decoded.payload.size && i < 100; i++) { if (msgRaw[i] == '\x07') { isAlert = true; break; } } if (isAlert) { if (longName && longName[0]) { snprintf(banner, sizeof(banner), "Alert Received\nfrom %s", longName); } else { strcpy(banner, "Alert Received"); } } else { if (longName && longName[0]) { snprintf(banner, sizeof(banner), "New Message\nfrom %s", longName); } else { strcpy(banner, "New Message"); } } screen->showOverlayBanner(banner, 3000); } } return 0; } // Triggered by MeshModules int Screen::handleUIFrameEvent(const UIFrameEvent *event) { 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 Attempt 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(); } return 0; } int Screen::handleInputEvent(const InputEvent *event) { if (NotificationRenderer::isOverlayBannerShowing()) { NotificationRenderer::inEvent = event->inputEvent; static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawAlertBannerOverlay}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); setFastFramerate(); // Draw ASAP ui->update(); return 0; } #if defined(DISPLAY_CLOCK_FRAME) // For the T-Watch, intercept touches to the 'toggle digital/analog watch face' button uint8_t watchFaceFrame = error_code ? 1 : 0; if (this->ui->getUiState()->currentFrame == watchFaceFrame && event->touchX >= 204 && event->touchX <= 240 && event->touchY >= 204 && event->touchY <= 240) { screen->digitalWatchFace = !screen->digitalWatchFace; setFrames(); return 0; } #endif // 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->interceptingKeyboardInput()) inputIntercepted = true; } // Only allow BUTTON_PRESSED and BUTTON_LONG_PRESSED to trigger frame changes if no module is handling input if (!inputIntercepted) { if (event->inputEvent == INPUT_BROKER_MSG_BUTTON_PRESSED) { showNextFrame(); return 0; } else if (event->inputEvent == INPUT_BROKER_MSG_BUTTON_LONG_PRESSED) { // Optional: Define alternate screen action or no-op return 0; } } // If no modules are using the input, move between frames if (!inputIntercepted) { if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) { showPrevFrame(); } else if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT)) { showNextFrame(); } else if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT) || event->inputEvent == INPUT_BROKER_MSG_BUTTON_DOUBLE_PRESSED) { #if HAS_TFT if (this->ui->getUiState()->currentFrame == framesetInfo.positions.memory) { showOverlayBanner("Switch to MUI?\nYES\nNO", 30000, 2, [](int selected) -> void { if (selected == 0) { config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR; config.bluetooth.enabled = false; service->reloadConfig(SEGMENT_CONFIG); rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); } }); } #endif #if HAS_GPS if (this->ui->getUiState()->currentFrame == framesetInfo.positions.gps) { showOverlayBanner("Toggle GPS\nENABLED\nDISABLED", 30000, 2, [](int selected) -> void { if (selected == 0) { config.position.gps_enabled = true; gps->enable(); } else if (selected == 1) { config.position.gps_enabled = false; gps->disable(); } }); } #endif } } } return 0; } int Screen::handleAdminMessage(const meshtastic_AdminMessage *arg) { switch (arg->which_payload_variant) { // Node removed manually (i.e. via app) case meshtastic_AdminMessage_remove_by_nodenum_tag: setFrames(FOCUS_PRESERVE); 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