/* SSD1306 - Screen module Copyright (C) 2018 by Xose Pérez 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 "../userPrefs.h" #include "PowerMon.h" #include "Throttle.h" #include "configuration.h" #if HAS_SCREEN #include #include "DisplayFormatters.h" #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" #endif #include "MeshService.h" #include "NodeDB.h" #include "error.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/images.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" #if HAS_WIFI && !defined(ARCH_PORTDUINO) #include "mesh/wifi/WiFiAPClient.h" #endif #ifdef ARCH_ESP32 #include "esp_task_wdt.h" #include "modules/StoreForwardModule.h" #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; uint32_t logo_timeout = 5000; // 4 seconds for EACH logo uint32_t hours_in_month = 730; // 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; // Stores the last 4 of our hardware ID, to make finding the device for pairing easier static char ourId[5]; // vector where symbols (string) are displayed in bottom corner of display. std::vector functionSymbals; // string displayed in bottom right corner of display. Created from elements in functionSymbals vector std::string functionSymbalString = ""; #if HAS_GPS // GeoCoord object for the screen GeoCoord geoCoord; #endif #ifdef SHOW_REDRAWS static bool heartbeat = false; #endif // Quick access to screen dimensions from static drawing functions // DEPRECATED. To-do: move static functions inside Screen class #define SCREEN_WIDTH display->getWidth() #define SCREEN_HEIGHT display->getHeight() #include "graphics/ScreenFonts.h" #include #define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) /// Check if the display can render a string (detect special chars; emoji) static bool haveGlyphs(const char *str) { #if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) // Don't want to make any assumptions about custom language support return true; #endif // Check each character with the lookup function for the OLED library // We're not really meant to use this directly.. bool have = true; for (uint16_t i = 0; i < strlen(str); i++) { uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]); // If font doesn't support a character, it is substituted for ¿ if (result == 191 && (uint8_t)str[i] != 191) { have = false; break; } } LOG_DEBUG("haveGlyphs=%d\n", have); return have; } /** * Draw the icon with extra info printed around the corners */ static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { // draw an xbm image. // Please note that everything that should be transitioned // needs to be drawn relative to x and y // draw centered icon left to right and centered above the one line of app text display->drawXbm(x + (SCREEN_WIDTH - icon_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - icon_height) / 2 + 2, icon_width, icon_height, icon_bits); display->setFont(FONT_MEDIUM); display->setTextAlignment(TEXT_ALIGN_LEFT); #ifdef SPLASH_TITLE_USERPREFS const char *title = SPLASH_TITLE_USERPREFS; #else const char *title = "meshtastic.org"; #endif display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); display->setFont(FONT_SMALL); // Draw region in upper left if (upperMsg) display->drawString(x + 0, y + 0, upperMsg); // Draw version and short name in upper right char buf[25]; snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); display->setTextAlignment(TEXT_ALIGN_RIGHT); display->drawString(x + SCREEN_WIDTH, y + 0, buf); screen->forceDisplay(); display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code } static void drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { // draw an xbm image. // Please note that everything that should be transitioned // needs to be drawn relative to x and y // draw centered icon left to right and centered above the one line of app text display->drawXbm(x + (SCREEN_WIDTH - oemStore.oem_icon_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - oemStore.oem_icon_height) / 2 + 2, oemStore.oem_icon_width, oemStore.oem_icon_height, (const uint8_t *)oemStore.oem_icon_bits.bytes); switch (oemStore.oem_font) { case 0: display->setFont(FONT_SMALL); break; case 2: display->setFont(FONT_LARGE); break; default: display->setFont(FONT_MEDIUM); break; } display->setTextAlignment(TEXT_ALIGN_LEFT); const char *title = oemStore.oem_text; display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); display->setFont(FONT_SMALL); // Draw region in upper left if (upperMsg) display->drawString(x + 0, y + 0, upperMsg); // Draw version and shortname in upper right char buf[25]; snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); display->setTextAlignment(TEXT_ALIGN_RIGHT); display->drawString(x + SCREEN_WIDTH, y + 0, buf); screen->forceDisplay(); display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code } static void drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { // Draw region in upper left const char *region = myRegion ? myRegion->name : NULL; drawOEMIconScreen(region, display, state, x, y); } void Screen::drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *message) { uint16_t x_offset = display->width() / 2; display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_MEDIUM); display->drawString(x_offset + x, 26 + y, message); } // Used on boot when a certificate is being created static void drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_SMALL); display->drawString(64 + x, y, "Creating SSL certificate"); #ifdef ARCH_ESP32 yield(); esp_task_wdt_reset(); #endif display->setFont(FONT_SMALL); if ((millis() / 1000) % 2) { display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ."); } else { display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . "); } } // Used when booting without a region set static void drawWelcomeScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_CENTER); display->drawString(64 + x, y, "//\\ E S H T /\\ S T / C"); display->drawString(64 + x, y + FONT_HEIGHT_SMALL, getDeviceName()); display->setTextAlignment(TEXT_ALIGN_LEFT); if ((millis() / 10000) % 2) { display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Set the region using the"); display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "Meshtastic Android, iOS,"); display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, "Web or CLI clients."); } else { display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Visit meshtastic.org"); display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "for more information."); display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, ""); } #ifdef ARCH_ESP32 yield(); esp_task_wdt_reset(); #endif } // draw overlay in bottom right corner of screen to show when notifications are muted or modifier key is active static void drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) { // LOG_DEBUG("Drawing function overlay\n"); if (functionSymbals.begin() != functionSymbals.end()) { char buf[64]; display->setFont(FONT_SMALL); snprintf(buf, sizeof(buf), "%s", functionSymbalString.c_str()); display->drawString(SCREEN_WIDTH - display->getStringWidth(buf), SCREEN_HEIGHT - FONT_HEIGHT_SMALL, buf); } } #ifdef USE_EINK /// Used on eink displays while in deep sleep static void drawDeepSleepScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { // Next frame should use full-refresh, and block while running, else device will sleep before async callback EINK_ADD_FRAMEFLAG(display, COSMETIC); EINK_ADD_FRAMEFLAG(display, BLOCKING); LOG_DEBUG("Drawing deep sleep screen\n"); // Display displayStr on the screen drawIconScreen("Sleeping", display, state, x, y); } /// Used on eink displays when screen updates are paused static void drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) { LOG_DEBUG("Drawing screensaver overlay\n"); EINK_ADD_FRAMEFLAG(display, COSMETIC); // Take the opportunity for a full-refresh // Config display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); const char *pauseText = "Screen Paused"; const char *idText = owner.short_name; const bool useId = haveGlyphs(idText); // This bool is used to hide the idText box if we can't render the short name constexpr uint16_t padding = 5; constexpr uint8_t dividerGap = 1; constexpr uint8_t imprecision = 5; // How far the box origins can drift from center. Combat burn-in. // Dimensions const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true); // "true": handle utf8 chars const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText)); const uint16_t boxWidth = padding + (useId ? idTextWidth + padding + padding : 0) + pauseTextWidth + padding; const uint16_t boxHeight = padding + FONT_HEIGHT_SMALL + padding; // Position const int16_t boxLeft = (display->width() / 2) - (boxWidth / 2) + random(-imprecision, imprecision + 1); // const int16_t boxRight = boxLeft + boxWidth - 1; const int16_t boxTop = (display->height() / 2) - (boxHeight / 2 + random(-imprecision, imprecision + 1)); const int16_t boxBottom = boxTop + boxHeight - 1; const int16_t idTextLeft = boxLeft + padding; const int16_t idTextTop = boxTop + padding; const int16_t pauseTextLeft = boxLeft + (useId ? padding + idTextWidth + padding : 0) + padding; const int16_t pauseTextTop = boxTop + padding; const int16_t dividerX = boxLeft + padding + idTextWidth + padding; const int16_t dividerTop = boxTop + 1 + dividerGap; const int16_t dividerBottom = boxBottom - 1 - dividerGap; // Draw: box display->setColor(EINK_WHITE); display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Clear a slightly oversized area for the box display->setColor(EINK_BLACK); display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); // Draw: Text if (useId) display->drawString(idTextLeft, idTextTop, idText); display->drawString(pauseTextLeft, pauseTextTop, pauseText); display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold // Draw: divider if (useId) display->drawLine(dividerX, dividerTop, dividerX, dividerBottom); } #endif 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\n\n", module_frame); } // LOG_DEBUG("Drawing Module Frame %d\n\n", module_frame); MeshModule &pi = *moduleFrames.at(module_frame); pi.drawFrame(display, state, x, y); } static void drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_MEDIUM); display->drawString(64 + x, y, "Updating"); display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(), "Please be patient and do not power off."); } /// Draw the last text message we received static void drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_MEDIUM); char tempBuf[24]; snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code); display->drawString(0 + x, 0 + y, tempBuf); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org"); } // 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; } // Draw power bars or a charging indicator on an image of a battery, determined by battery charge voltage or percentage. static void drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer, const PowerStatus *powerStatus) { static const uint8_t powerBar[3] = {0x81, 0xBD, 0xBD}; static const uint8_t lightning[8] = {0xA1, 0xA1, 0xA5, 0xAD, 0xB5, 0xA5, 0x85, 0x85}; // Clear the bar area on the battery image for (int i = 1; i < 14; i++) { imgBuffer[i] = 0x81; } // If charging, draw a charging indicator if (powerStatus->getIsCharging()) { memcpy(imgBuffer + 3, lightning, 8); // If not charging, Draw power bars } else { for (int i = 0; i < 4; i++) { if (powerStatus->getBatteryChargePercent() >= 25 * i) memcpy(imgBuffer + 1 + (i * 3), powerBar, 3); } } display->drawFastImage(x, y, 16, 8, imgBuffer); } #ifdef T_WATCH_S3 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); drawBattery(display, x, y + 7, imgBattery, powerStatus); if (powerStatus->getHasBattery()) { String batteryPercent = String(powerStatus->getBatteryChargePercent()) + "%"; display->setFont(FONT_SMALL); display->drawString(x + 20, y + 2, batteryPercent); } if (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; } // hours string String hourString = String(hour); // minutes string String minuteString = minute < 10 ? "0" + String(minute) : String(minute); String timeString = hourString + ":" + minuteString; // seconds string String secondString = second < 10 ? "0" + String(second) : String(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 = timeString.length() * 5; for (uint8_t i = 0; i < timeString.length(); i++) { String character = String(timeString[i]); if (character == ":") { timeStringWidth += segmentHeight; } else { timeStringWidth += segmentWidth + (segmentHeight * 2) + 4; } } // calculate seconds string width uint16_t secondStringWidth = (secondString.length() * 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 < timeString.length(); i++) { String character = String(timeString[i]); if (character == ":") { drawSegmentedDisplayColon(display, hourMinuteTextX, hourMinuteTextY, scale); hourMinuteTextX += segmentHeight + 6; } else { drawSegmentedDisplayCharacter(display, hourMinuteTextX, hourMinuteTextY, character.toInt(), 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); drawBattery(display, x, y + 7, imgBattery, powerStatus); if (powerStatus->getHasBattery()) { String batteryPercent = String(powerStatus->getBatteryChargePercent()) + "%"; display->setFont(FONT_SMALL); display->drawString(x + 20, y + 2, batteryPercent); } if (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; } /// Draw the last text message we received static void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { // the max length of this buffer is much longer than we can possibly print static char tempBuf[237]; const meshtastic_MeshPacket &mp = devicestate.rx_text_message; meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp)); // LOG_DEBUG("drawing text message from 0x%x: %s\n", mp.from, // mp.decoded.variant.data.decoded.bytes); // Demo for drawStringMaxWidth: // with the third parameter you can define the width after which words will // be wrapped. Currently only spaces and "-" are allowed for wrapping display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); display->setColor(BLACK); } // For time delta uint32_t seconds = sinceReceived(&mp); uint32_t minutes = seconds / 60; uint32_t hours = minutes / 60; uint32_t days = hours / 24; // For timestamp uint8_t timestampHours, timestampMinutes; int32_t daysAgo; bool useTimestamp = deltaToTimestamp(seconds, ×tampHours, ×tampMinutes, &daysAgo); // If bold, draw twice, shifting right by one pixel for (uint8_t xOff = 0; xOff <= (config.display.heading_bold ? 1 : 0); xOff++) { // Show a timestamp if received today, but longer than 15 minutes ago if (useTimestamp && minutes >= 15 && daysAgo == 0) { display->drawStringf(xOff + x, 0 + y, tempBuf, "At %02hu:%02hu from %s", timestampHours, timestampMinutes, (node && node->has_user) ? node->user.short_name : "???"); } // Timestamp yesterday (if display is wide enough) else if (useTimestamp && daysAgo == 1 && display->width() >= 200) { display->drawStringf(xOff + x, 0 + y, tempBuf, "Yesterday %02hu:%02hu from %s", timestampHours, timestampMinutes, (node && node->has_user) ? node->user.short_name : "???"); } // Otherwise, show a time delta else { display->drawStringf(xOff + x, 0 + y, tempBuf, "%s ago from %s", screen->drawTimeDelta(days, hours, minutes, seconds).c_str(), (node && node->has_user) ? node->user.short_name : "???"); } } display->setColor(WHITE); #ifndef EXCLUDE_EMOJI if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"\U0001F44D") == 0) { display->drawXbm(x + (SCREEN_WIDTH - thumbs_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - thumbs_height) / 2 + 2 + 5, thumbs_width, thumbs_height, thumbup); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"\U0001F44E") == 0) { display->drawXbm(x + (SCREEN_WIDTH - thumbs_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - thumbs_height) / 2 + 2 + 5, thumbs_width, thumbs_height, thumbdown); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"❓") == 0) { display->drawXbm(x + (SCREEN_WIDTH - question_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - question_height) / 2 + 2 + 5, question_width, question_height, question); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"‼️") == 0) { display->drawXbm(x + (SCREEN_WIDTH - bang_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - bang_height) / 2 + 2 + 5, bang_width, bang_height, bang); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"\U0001F4A9") == 0) { display->drawXbm(x + (SCREEN_WIDTH - poo_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - poo_height) / 2 + 2 + 5, poo_width, poo_height, poo); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), "\xf0\x9f\xa4\xa3") == 0) { display->drawXbm(x + (SCREEN_WIDTH - haha_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - haha_height) / 2 + 2 + 5, haha_width, haha_height, haha); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"\U0001F44B") == 0) { display->drawXbm(x + (SCREEN_WIDTH - wave_icon_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - wave_icon_height) / 2 + 2 + 5, wave_icon_width, wave_icon_height, wave_icon); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"\U0001F920") == 0) { display->drawXbm(x + (SCREEN_WIDTH - cowboy_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - cowboy_height) / 2 + 2 + 5, cowboy_width, cowboy_height, cowboy); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"\U0001F42D") == 0) { display->drawXbm(x + (SCREEN_WIDTH - deadmau5_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - deadmau5_height) / 2 + 2 + 5, deadmau5_width, deadmau5_height, deadmau5); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"\xE2\x98\x80\xEF\xB8\x8F") == 0) { display->drawXbm(x + (SCREEN_WIDTH - sun_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - sun_height) / 2 + 2 + 5, sun_width, sun_height, sun); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"\u2614") == 0) { display->drawXbm(x + (SCREEN_WIDTH - rain_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - rain_height) / 2 + 2 + 10, rain_width, rain_height, rain); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"☁️") == 0) { display->drawXbm(x + (SCREEN_WIDTH - cloud_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - cloud_height) / 2 + 2 + 5, cloud_width, cloud_height, cloud); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"🌫️") == 0) { display->drawXbm(x + (SCREEN_WIDTH - fog_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - fog_height) / 2 + 2 + 5, fog_width, fog_height, fog); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"\xf0\x9f\x98\x88") == 0) { display->drawXbm(x + (SCREEN_WIDTH - devil_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - devil_height) / 2 + 2 + 5, devil_width, devil_height, devil); } else if (strcmp(reinterpret_cast(mp.decoded.payload.bytes), u8"♥️") == 0) { display->drawXbm(x + (SCREEN_WIDTH - heart_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - heart_height) / 2 + 2 + 5, heart_width, heart_height, heart); } else { snprintf(tempBuf, sizeof(tempBuf), "%s", mp.decoded.payload.bytes); display->drawStringMaxWidth(0 + x, 0 + y + FONT_HEIGHT_SMALL, x + display->getWidth(), tempBuf); } #else snprintf(tempBuf, sizeof(tempBuf), "%s", mp.decoded.payload.bytes); display->drawStringMaxWidth(0 + x, 0 + y + FONT_HEIGHT_SMALL, x + display->getWidth(), tempBuf); #endif } /// Draw a series of fields in a column, wrapping to multiple columns if needed void Screen::drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields) { // The coordinates define the left starting point of the text display->setTextAlignment(TEXT_ALIGN_LEFT); const char **f = fields; int xo = x, yo = y; while (*f) { display->drawString(xo, yo, *f); if ((display->getColor() == BLACK) && config.display.heading_bold) display->drawString(xo + 1, yo, *f); display->setColor(WHITE); yo += FONT_HEIGHT_SMALL; if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) { xo += SCREEN_WIDTH / 2; yo = 0; } f++; } } // Draw nodes status static void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const NodeStatus *nodeStatus) { char usersString[20]; snprintf(usersString, sizeof(usersString), "%d/%d", nodeStatus->getNumOnline(), nodeStatus->getNumTotal()); #if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || defined(ST7789_CS) || \ defined(USE_ST7789) || defined(HX8357_CS)) && \ !defined(DISPLAY_FORCE_SMALL_FONTS) display->drawFastImage(x, y + 3, 8, 8, imgUser); #else display->drawFastImage(x, y, 8, 8, imgUser); #endif display->drawString(x + 10, y - 2, usersString); if (config.display.heading_bold) display->drawString(x + 11, y - 2, usersString); } #if HAS_GPS // Draw GPS status summary static void drawGPS(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) { if (config.position.fixed_position) { // GPS coordinates are currently fixed display->drawString(x - 1, y - 2, "Fixed GPS"); if (config.display.heading_bold) display->drawString(x, y - 2, "Fixed GPS"); return; } if (!gps->getIsConnected()) { display->drawString(x, y - 2, "No GPS"); if (config.display.heading_bold) display->drawString(x + 1, y - 2, "No GPS"); return; } display->drawFastImage(x, y, 6, 8, gps->getHasLock() ? imgPositionSolid : imgPositionEmpty); if (!gps->getHasLock()) { display->drawString(x + 8, y - 2, "No sats"); if (config.display.heading_bold) display->drawString(x + 9, y - 2, "No sats"); return; } else { char satsString[3]; uint8_t bar[2] = {0}; // Draw DOP signal bars for (int i = 0; i < 5; i++) { if (gps->getDOP() <= dopThresholds[i]) bar[0] = ~((1 << (5 - i)) - 1); else bar[0] = 0b10000000; // bar[1] = bar[0]; display->drawFastImage(x + 9 + (i * 2), y, 2, 8, bar); } // Draw satellite image display->drawFastImage(x + 24, y, 8, 8, imgSatellite); // Draw the number of satellites snprintf(satsString, sizeof(satsString), "%u", gps->getNumSatellites()); display->drawString(x + 34, y - 2, satsString); if (config.display.heading_bold) display->drawString(x + 35, y - 2, satsString); } } // Draw status when GPS is disabled or not present static void drawGPSpowerstat(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) { String displayLine; int pos; if (y < FONT_HEIGHT_SMALL) { // Line 1: use short string displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; pos = SCREEN_WIDTH - display->getStringWidth(displayLine); } else { displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "GPS not present" : "GPS is disabled"; pos = (SCREEN_WIDTH - display->getStringWidth(displayLine)) / 2; } display->drawString(x + pos, y, displayLine); } static void drawGPSAltitude(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) { String displayLine = ""; if (!gps->getIsConnected() && !config.position.fixed_position) { // displayLine = "No GPS Module"; // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); } else if (!gps->getHasLock() && !config.position.fixed_position) { // displayLine = "No GPS Lock"; // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); } else { geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); displayLine = "Altitude: " + String(geoCoord.getAltitude()) + "m"; if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) displayLine = "Altitude: " + String(geoCoord.getAltitude() * METERS_TO_FEET) + "ft"; display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); } } // Draw GPS status coordinates static void drawGPScoordinates(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) { auto gpsFormat = config.display.gps_format; String displayLine = ""; if (!gps->getIsConnected() && !config.position.fixed_position) { displayLine = "No GPS present"; display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); } else if (!gps->getHasLock() && !config.position.fixed_position) { displayLine = "No GPS Lock"; display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); } else { geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); if (gpsFormat != meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) { char coordinateLine[22]; if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DEC) { // Decimal Degrees snprintf(coordinateLine, sizeof(coordinateLine), "%f %f", geoCoord.getLatitude() * 1e-7, geoCoord.getLongitude() * 1e-7); } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_UTM) { // Universal Transverse Mercator snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %06u %07u", geoCoord.getUTMZone(), geoCoord.getUTMBand(), geoCoord.getUTMEasting(), geoCoord.getUTMNorthing()); } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_MGRS) { // Military Grid Reference System snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %1c%1c %05u %05u", geoCoord.getMGRSZone(), geoCoord.getMGRSBand(), geoCoord.getMGRSEast100k(), geoCoord.getMGRSNorth100k(), geoCoord.getMGRSEasting(), geoCoord.getMGRSNorthing()); } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OLC) { // Open Location Code geoCoord.getOLCCode(coordinateLine); } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OSGR) { // Ordnance Survey Grid Reference if (geoCoord.getOSGRE100k() == 'I' || geoCoord.getOSGRN100k() == 'I') // OSGR is only valid around the UK region snprintf(coordinateLine, sizeof(coordinateLine), "%s", "Out of Boundary"); else snprintf(coordinateLine, sizeof(coordinateLine), "%1c%1c %05u %05u", geoCoord.getOSGRE100k(), geoCoord.getOSGRN100k(), geoCoord.getOSGREasting(), geoCoord.getOSGRNorthing()); } // If fixed position, display text "Fixed GPS" alternating with the coordinates. if (config.position.fixed_position) { if ((millis() / 10000) % 2) { display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine); } else { display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth("Fixed GPS"))) / 2, y, "Fixed GPS"); } } else { display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine); } } else { char latLine[22]; char lonLine[22]; snprintf(latLine, sizeof(latLine), "%2i° %2i' %2u\" %1c", geoCoord.getDMSLatDeg(), geoCoord.getDMSLatMin(), geoCoord.getDMSLatSec(), geoCoord.getDMSLatCP()); snprintf(lonLine, sizeof(lonLine), "%3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(), geoCoord.getDMSLonMin(), geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP()); display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(latLine))) / 2, y - FONT_HEIGHT_SMALL * 1, latLine); display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(lonLine))) / 2, y, lonLine); } } } #endif /** * 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 size_t nodeIndex; static int8_t prevFrame = -1; // Draw the arrow pointing to a node's location void Screen::drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian) { Point tip(0.0f, 0.5f), tail(0.0f, -0.35f); // pointing up initially float arrowOffsetX = 0.14f, arrowOffsetY = 1.0f; Point leftArrow(tip.x - arrowOffsetX, tip.y - arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y - arrowOffsetY); Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow}; for (int i = 0; i < 4; i++) { arrowPoints[i]->rotate(headingRadian); arrowPoints[i]->scale(compassDiam * 0.6); arrowPoints[i]->translate(compassX, compassY); } /* Old arrow display->drawLine(tip.x, tip.y, tail.x, tail.y); display->drawLine(leftArrow.x, leftArrow.y, tip.x, tip.y); display->drawLine(rightArrow.x, rightArrow.y, tip.x, tip.y); display->drawLine(leftArrow.x, leftArrow.y, tail.x, tail.y); display->drawLine(rightArrow.x, rightArrow.y, tail.x, tail.y); */ #ifdef USE_EINK display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); #else display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); #endif display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y); } // 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"); } void Screen::drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading) { // If north is supposed to be at the top of the compass we want rotation to be +0 if (config.display.compass_north_top) myHeading = -0; /* N sign points currently not deleted*/ Point N1(-0.04f, 0.65f), N2(0.04f, 0.65f); // N sign points (N1-N4) Point N3(-0.04f, 0.55f), N4(0.04f, 0.55f); Point NC1(0.00f, 0.50f); // north circle center point Point *rosePoints[] = {&N1, &N2, &N3, &N4, &NC1}; uint16_t compassDiam = Screen::getCompassDiam(SCREEN_WIDTH, SCREEN_HEIGHT); for (int i = 0; i < 5; i++) { // North on compass will be negative of heading rosePoints[i]->rotate(-myHeading); rosePoints[i]->scale(compassDiam); rosePoints[i]->translate(compassX, compassY); } /* changed the N sign to a small circle on the compass circle. display->drawLine(N1.x, N1.y, N3.x, N3.y); display->drawLine(N2.x, N2.y, N4.x, N4.y); display->drawLine(N1.x, N1.y, N4.x, N4.y); */ display->drawCircle(NC1.x, NC1.y, 4); // North sign circle, 4px radius is sufficient for all displays. } uint16_t Screen::getCompassDiam(uint32_t displayWidth, uint32_t displayHeight) { uint16_t diam = 0; uint16_t offset = 0; if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) offset = FONT_HEIGHT_SMALL; // get the smaller of the 2 dimensions and subtract 20 if (displayWidth > (displayHeight - offset)) { diam = displayHeight - offset; // if 2/3 of the other size would be smaller, use that if (diam > (displayWidth * 2 / 3)) { diam = displayWidth * 2 / 3; } } else { diam = displayWidth; if (diam > ((displayHeight - offset) * 2 / 3)) { diam = (displayHeight - offset) * 2 / 3; } } return diam - 20; }; static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { // We only advance our nodeIndex if the frame # has changed - because // drawNodeInfo will be called repeatedly while the frame is shown if (state->currentFrame != prevFrame) { prevFrame = state->currentFrame; nodeIndex = (nodeIndex + 1) % nodeDB->getNumMeshNodes(); meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(nodeIndex); if (n->num == nodeDB->getNodeNum()) { // Don't show our node, just skip to next nodeIndex = (nodeIndex + 1) % nodeDB->getNumMeshNodes(); n = nodeDB->getMeshNodeByIndex(nodeIndex); } } meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(nodeIndex); display->setFont(FONT_SMALL); // The coordinates define the left starting point of the text display->setTextAlignment(TEXT_ALIGN_LEFT); if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); } const char *username = node->has_user ? node->user.long_name : "Unknown Name"; static char signalStr[20]; // section here to choose whether to display hops away rather than signal strength if more than 0 hops away. if (node->hops_away > 0) { snprintf(signalStr, sizeof(signalStr), "Hops Away: %d", node->hops_away); } else { snprintf(signalStr, sizeof(signalStr), "Signal: %d%%", clamp((int)((node->snr + 10) * 5), 0, 100)); } static char lastStr[20]; screen->getTimeAgoStr(sinceLastSeen(node), lastStr, sizeof(lastStr)); static char distStr[20]; if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { strncpy(distStr, "? mi", sizeof(distStr)); // might not have location data } else { strncpy(distStr, "? km", sizeof(distStr)); } meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); const char *fields[] = {username, lastStr, signalStr, distStr, NULL}; int16_t compassX = 0, compassY = 0; uint16_t compassDiam = Screen::getCompassDiam(SCREEN_WIDTH, SCREEN_HEIGHT); // coordinates for the center of the compass/circle if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { compassX = x + SCREEN_WIDTH - compassDiam / 2 - 5; compassY = y + SCREEN_HEIGHT / 2; } else { compassX = x + SCREEN_WIDTH - compassDiam / 2 - 5; compassY = y + FONT_HEIGHT_SMALL + (SCREEN_HEIGHT - FONT_HEIGHT_SMALL) / 2; } bool hasNodeHeading = false; if (ourNode && (hasValidPosition(ourNode) || screen->hasHeading())) { const meshtastic_PositionLite &op = ourNode->position; float myHeading; if (screen->hasHeading()) myHeading = (screen->getHeading()) * PI / 180; // gotta convert compass degrees to Radians else myHeading = screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i)); screen->drawCompassNorth(display, compassX, compassY, myHeading); if (hasValidPosition(node)) { // display direction toward node hasNodeHeading = true; const meshtastic_PositionLite &p = node->position; float d = GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { if (d < (2 * MILES_TO_FEET)) snprintf(distStr, sizeof(distStr), "%.0f ft", d * METERS_TO_FEET); else snprintf(distStr, sizeof(distStr), "%.1f mi", d * METERS_TO_FEET / MILES_TO_FEET); } else { if (d < 2000) snprintf(distStr, sizeof(distStr), "%.0f m", d); else snprintf(distStr, sizeof(distStr), "%.1f km", d / 1000); } float bearingToOther = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i)); // If the top of the compass is a static north then bearingToOther can be drawn on the compass directly // If the top of the compass is not a static north we need adjust bearingToOther based on heading if (!config.display.compass_north_top) bearingToOther -= myHeading; screen->drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); } } if (!hasNodeHeading) { // direction to node is unknown so display question mark // Debug info for gps lock errors // LOG_DEBUG("ourNode %d, ourPos %d, theirPos %d\n", !!ourNode, ourNode && hasValidPosition(ourNode), // hasValidPosition(node)); display->drawString(compassX - FONT_HEIGHT_SMALL / 4, compassY - FONT_HEIGHT_SMALL / 2, "?"); } display->drawCircle(compassX, compassY, compassDiam / 2); if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { display->setColor(BLACK); } // Must be after distStr is populated screen->drawColumns(display, x, y, fields); } #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); #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(ST7701_CS) || defined(ST7789_CS) || defined(RAK14014) || \ defined(HX8357_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 (settingsMap[displayPanel] != no_screen) { LOG_DEBUG("Making TFTDisplay!\n"); 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, drawDeepSleepScreen); #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("Turning on screen\n"); powerMon->setState(meshtastic_PowerMon_State_Screen_On); #ifdef T_WATCH_S3 PMU->enablePowerOutput(XPOWERS_ALDO2); #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 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("Turning off screen\n"); 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() { // We don't set useDisplay until setup() is called, because some boards have a declaration of this object but the device // is never found when probing i2c and therefore we don't call setup and never want to do (invalid) accesses to this device. useDisplay = true; #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) // Heltec T114 and T190: honor a custom text color, if defined in variant.h static_cast(dispdev)->setRGB(TFT_MESH); #endif // Initialising the UI will init the display too. ui->init(); displayWidth = dispdev->width(); displayHeight = dispdev->height(); ui->setTimePerTransition(0); ui->setIndicatorPosition(BOTTOM); // Defines where the first frame is located in the bar. ui->setIndicatorDirection(LEFT_RIGHT); ui->setFrameAnimation(SLIDE_LEFT); // Don't show the page swipe dots while in boot screen. ui->disableAllIndicators(); // Store a pointer to Screen so we can get to it from static functions. ui->getUiState()->userData = this; // Set the utf8 conversion function dispdev->setFontTableLookupFunction(customFontTableLookup); if (strlen(oemStore.oem_text) > 0) logo_timeout *= 2; // Add frames. EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { #ifdef ARCH_ESP32 if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1) { drawFrameText(display, state, x, y, "Resuming..."); } else #endif { // Draw region in upper left const char *region = myRegion ? myRegion->name : NULL; drawIconScreen(region, display, state, x, y); } }; ui->setFrames(alertFrames, 1); // No overlays. ui->setOverlays(nullptr, 0); // Require presses to switch between frames. ui->disableAutoTransition(); // Set up a log buffer with 3 lines, 32 chars each. dispdev->setLogBuffer(3, 32); #ifdef SCREEN_MIRROR dispdev->mirrorScreen(); #else // Standard behaviour is to FLIP the screen (needed on T-Beam). If this config item is set, unflip it, and thereby logically // flip it. If you have a headache now, you're welcome. if (!config.display.flip_screen) { #if defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ST7701_CS) || defined(ST7789_CS) || \ defined(RAK14014) || defined(HX8357_CS) static_cast(dispdev)->flipScreenVertically(); #elif defined(USE_ST7789) static_cast(dispdev)->flipScreenVertically(); #else dispdev->flipScreenVertically(); #endif } #endif // Get our hardware ID uint8_t dmac[6]; getMacAddr(dmac); snprintf(ourId, sizeof(ourId), "%02x%02x", dmac[4], dmac[5]); #if ARCH_PORTDUINO handleSetOn(false); // force clean init #endif // Turn on the display. handleSetOn(true); // On some ssd1306 clones, the first draw command is discarded, so draw it // twice initially. Skip this for EINK Displays to save a few seconds during boot ui->update(); #ifndef USE_EINK ui->update(); #endif serialSinceMsec = millis(); #if ARCH_PORTDUINO 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 status updates powerStatusObserver.observe(&powerStatus->onNewStatus); gpsStatusObserver.observe(&gpsStatus->onNewStatus); nodeStatusObserver.observe(&nodeStatus->onNewStatus); adminMessageObserver.observe(adminModule); if (textMessageModule) textMessageObserver.observe(textMessageModule); if (inputBroker) inputObserver.observe(inputBroker); // Modules can notify screen about refresh 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...\n"); stopBootScreen(); showingBootScreen = false; } // If we have an OEM Boot screen, toggle after logo_timeout seconds if (strlen(oemStore.oem_text) > 0) { static bool showingOEMBootScreen = true; if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) { LOG_INFO("Switch to OEM screen...\n"); // Change frames. static FrameCallback bootOEMFrames[] = {drawOEMBootScreen}; static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]); ui->setFrames(bootOEMFrames, bootOEMFrameCount); ui->update(); #ifndef USE_EINK ui->update(); #endif showingOEMBootScreen = false; } } #ifndef DISABLE_WELCOME_UNSET if (showingNormalScreen && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { setWelcomeFrames(); } #endif // 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 a nag notification is running, stop it if (moduleConfig.external_notification.enabled && (externalNotificationModule->nagCycleCutoff != UINT32_MAX)) { externalNotificationModule->stopNow(); } else { // Don't advance the screen if we just wanted to switch off the nag notification 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\n"); } } 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 transitioning to next frame\n", (millis() - lastScreenTransition)); handleOnPress(); } } // LOG_DEBUG("want fps %d, fixed=%d\n", 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); } void Screen::drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { Screen *screen2 = reinterpret_cast(state->userData); screen2->debugInfo.drawFrame(display, state, x, y); } void Screen::drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { Screen *screen2 = reinterpret_cast(state->userData); screen2->debugInfo.drawFrameSettings(display, state, x, y); } void Screen::drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { Screen *screen2 = reinterpret_cast(state->userData); screen2->debugInfo.drawFrameWiFi(display, state, x, y); } /* 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("showing SSL frames\n"); static FrameCallback sslFrames[] = {drawSSLScreen}; ui->setFrames(sslFrames, 1); ui->update(); } } /* show a message that the SSL cert is being built * it is expected that this will be used during the boot phase */ void Screen::setWelcomeFrames() { if (address_found.address) { // LOG_DEBUG("showing Welcome frames\n"); static FrameCallback frames[] = {drawWelcomeScreen}; setFrameImmediateDraw(frames); } } #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 = 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("showing standard frames\n"); showingNormalScreen = true; #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("Showing %d module frames\n", moduleFrames.size()); #ifdef DEBUG_PORT int totalFrameCount = MAX_NUM_NODES + NUM_EXTRA_FRAMES + moduleFrames.size(); LOG_DEBUG("Total frame count: %d\n", 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; } // Identify the position of specific modules, if we need to know this later if (m == waypointModule) fsi.positions.waypoint = numframes; numframes++; } LOG_DEBUG("Added modules. numframes: %d\n", numframes); // If we have a critical fault, show it first fsi.positions.fault = numframes; if (error_code) { normalFrames[numframes++] = drawCriticalFaultFrame; focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame } #ifdef T_WATCH_S3 normalFrames[numframes++] = screen->digitalWatchFace ? &Screen::drawDigitalClockFrame : &Screen::drawAnalogClockFrame; #endif // If we have a text message - show it next, unless it's a phone message and we aren't using any special modules if (devicestate.has_rx_text_message && shouldDrawMessage(&devicestate.rx_text_message)) { fsi.positions.textMessage = numframes; normalFrames[numframes++] = drawTextMessageFrame; } // then all the nodes // We only show a few nodes in our scrolling list - because meshes with many nodes would have too many screens size_t numToShow = min(numMeshNodes, 4U); for (size_t i = 0; i < numToShow; i++) normalFrames[numframes++] = drawNodeInfo; // 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++] = &Screen::drawDebugInfoTrampoline; // call a method on debugInfoScreen object (for more details) fsi.positions.settings = numframes; normalFrames[numframes++] = &Screen::drawDebugInfoSettingsTrampoline; fsi.positions.wifi = numframes; #if HAS_WIFI && !defined(ARCH_PORTDUINO) if (isWifiAvailable()) { // call a method on debugInfoScreen object (for more details) normalFrames[numframes++] = &Screen::drawDebugInfoWiFiTrampoline; } #endif fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE LOG_DEBUG("Finished building frames. numframes: %d\n", numframes); ui->setFrames(normalFrames, numframes); ui->enableAllIndicators(); // Add function overlay here. This can show when notifications muted, modifier key is active etc static OverlayCallback functionOverlay[] = {drawFunctionOverlay}; static const int functionOverlayCount = sizeof(functionOverlay) / sizeof(functionOverlay[0]); ui->setOverlays(functionOverlay, functionOverlayCount); 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(0); // First frame break; case FOCUS_FAULT: ui->switchToFrame(fsi.positions.fault); break; case FOCUS_TEXTMESSAGE: 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: // If we can identify which type of frame "originalPosition" was, can move directly to it in the new frameset const FramesetInfo &oldFsi = this->framesetInfo; if (originalPosition == oldFsi.positions.log) ui->switchToFrame(fsi.positions.log); else if (originalPosition == oldFsi.positions.settings) ui->switchToFrame(fsi.positions.settings); else if (originalPosition == oldFsi.positions.wifi) ui->switchToFrame(fsi.positions.wifi); // If frame count has decreased else if (fsi.frameCount < oldFsi.frameCount) { uint8_t numDropped = oldFsi.frameCount - fsi.frameCount; // Move n frames backwards if (numDropped <= originalPosition) ui->switchToFrame(originalPosition - numDropped); // Unless that would put us "out of bounds" (< 0) else ui->switchToFrame(0); } // If we're not sure exactly which frame we were on, at least return to the same frame number // (node frames; module frames) else ui->switchToFrame(originalPosition); 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("Dismissing Text Message\n"); devicestate.has_rx_text_message = false; dismissed = true; } else if (currentFrame == framesetInfo.positions.waypoint && devicestate.has_rx_waypoint) { LOG_DEBUG("Dismissing Waypoint\n"); devicestate.has_rx_waypoint = false; dismissed = true; } // If we did make changes to dismiss, we now need to regenerate the frameset if (dismissed) setFrames(); } void Screen::handleStartFirmwareUpdateScreen() { LOG_DEBUG("showing firmware screen\n"); showingNormalScreen = false; EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // E-Ink: Explicitly use fast-refresh for next frame static FrameCallback frames[] = {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::setFunctionSymbal(std::string sym) { if (std::find(functionSymbals.begin(), functionSymbals.end(), sym) == functionSymbals.end()) { functionSymbals.push_back(sym); functionSymbalString = ""; for (auto symbol : functionSymbals) { functionSymbalString = symbol + " " + functionSymbalString; } setFastFramerate(); } } void Screen::removeFunctionSymbal(std::string sym) { functionSymbals.erase(std::remove(functionSymbals.begin(), functionSymbals.end(), sym), functionSymbals.end()); functionSymbalString = ""; for (auto symbol : functionSymbals) { functionSymbalString = symbol + " " + functionSymbalString; } setFastFramerate(); } std::string Screen::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds) { std::string uptime; if (days > (hours_in_month * 6)) uptime = "?"; else if (days >= 2) uptime = std::to_string(days) + "d"; else if (hours >= 2) uptime = std::to_string(hours) + "h"; else if (minutes >= 1) uptime = std::to_string(minutes) + "m"; else uptime = std::to_string(seconds) + "s"; return uptime; } 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\n", 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; } void DebugInfo::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setFont(FONT_SMALL); // The coordinates define the left starting point of the text display->setTextAlignment(TEXT_ALIGN_LEFT); if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); display->setColor(BLACK); } char channelStr[20]; { concurrency::LockGuard guard(&lock); snprintf(channelStr, sizeof(channelStr), "#%s", channels.getName(channels.getPrimaryIndex())); } // Display power status if (powerStatus->getHasBattery()) { if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { drawBattery(display, x, y + 2, imgBattery, powerStatus); } else { drawBattery(display, x + 1, y + 3, imgBattery, powerStatus); } } else if (powerStatus->knowsUSB()) { if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { display->drawFastImage(x, y + 2, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); } else { display->drawFastImage(x + 1, y + 3, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); } } // Display nodes status if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 2, nodeStatus); } else { drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 3, nodeStatus); } #if HAS_GPS // Display GPS status if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { drawGPSpowerstat(display, x, y + 2, gpsStatus); } else { if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { drawGPS(display, x + (SCREEN_WIDTH * 0.63), y + 2, gpsStatus); } else { drawGPS(display, x + (SCREEN_WIDTH * 0.63), y + 3, gpsStatus); } } #endif display->setColor(WHITE); // Draw the channel name display->drawString(x, y + FONT_HEIGHT_SMALL, channelStr); // Draw our hardware ID to assist with bluetooth pairing. Either prefix with Info or S&F Logo if (moduleConfig.store_forward.enabled) { #ifdef ARCH_ESP32 if (!Throttle::isWithinTimespanMs(storeForwardModule->lastHeartbeat, (storeForwardModule->heartbeatInterval * 1200))) { // no heartbeat, overlap a bit #if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || defined(ST7789_CS) || \ defined(USE_ST7789) || defined(HX8357_CS)) && \ !defined(DISPLAY_FORCE_SMALL_FONTS) display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8, imgQuestionL1); display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8, imgQuestionL2); #else display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8, imgQuestion); #endif } else { #if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || defined(ST7789_CS) || \ defined(USE_ST7789) || defined(HX8357_CS)) && \ !defined(DISPLAY_FORCE_SMALL_FONTS) display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 16, 8, imgSFL1); display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 16, 8, imgSFL2); #else display->drawFastImage(x + SCREEN_WIDTH - 13 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 11, 8, imgSF); #endif } #endif } else { // TODO: Raspberry Pi supports more than just the one screen size #if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || defined(ST7789_CS) || \ defined(USE_ST7789) || defined(HX8357_CS) || ARCH_PORTDUINO) && \ !defined(DISPLAY_FORCE_SMALL_FONTS) display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8, imgInfoL1); display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8, imgInfoL2); #else display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8, imgInfo); #endif } display->drawString(x + SCREEN_WIDTH - display->getStringWidth(ourId), y + FONT_HEIGHT_SMALL, ourId); // Draw any log messages display->drawLogBuffer(x, y + (FONT_HEIGHT_SMALL * 2)); /* Display a heartbeat pixel that blinks every time the frame is redrawn */ #ifdef SHOW_REDRAWS if (heartbeat) display->setPixel(0, 0); heartbeat = !heartbeat; #endif } // Jm void DebugInfo::drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { #if HAS_WIFI && !defined(ARCH_PORTDUINO) const char *wifiName = config.network.wifi_ssid; display->setFont(FONT_SMALL); // The coordinates define the left starting point of the text display->setTextAlignment(TEXT_ALIGN_LEFT); if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); display->setColor(BLACK); } if (WiFi.status() != WL_CONNECTED) { display->drawString(x, y, String("WiFi: Not Connected")); if (config.display.heading_bold) display->drawString(x + 1, y, String("WiFi: Not Connected")); } else { display->drawString(x, y, String("WiFi: Connected")); if (config.display.heading_bold) display->drawString(x + 1, y, String("WiFi: Connected")); display->drawString(x + SCREEN_WIDTH - display->getStringWidth("RSSI " + String(WiFi.RSSI())), y, "RSSI " + String(WiFi.RSSI())); if (config.display.heading_bold) { display->drawString(x + SCREEN_WIDTH - display->getStringWidth("RSSI " + String(WiFi.RSSI())) - 1, y, "RSSI " + String(WiFi.RSSI())); } } display->setColor(WHITE); /* - WL_CONNECTED: assigned when connected to a WiFi network; - WL_NO_SSID_AVAIL: assigned when no SSID are available; - WL_CONNECT_FAILED: assigned when the connection fails for all the attempts; - WL_CONNECTION_LOST: assigned when the connection is lost; - WL_DISCONNECTED: assigned when disconnected from a network; - WL_IDLE_STATUS: it is a temporary status assigned when WiFi.begin() is called and remains active until the number of attempts expires (resulting in WL_CONNECT_FAILED) or a connection is established (resulting in WL_CONNECTED); - WL_SCAN_COMPLETED: assigned when the scan networks is completed; - WL_NO_SHIELD: assigned when no WiFi shield is present; */ if (WiFi.status() == WL_CONNECTED) { display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "IP: " + String(WiFi.localIP().toString().c_str())); } else if (WiFi.status() == WL_NO_SSID_AVAIL) { display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "SSID Not Found"); } else if (WiFi.status() == WL_CONNECTION_LOST) { display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Connection Lost"); } else if (WiFi.status() == WL_CONNECT_FAILED) { display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Connection Failed"); } else if (WiFi.status() == WL_IDLE_STATUS) { display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Idle ... Reconnecting"); } #ifdef ARCH_ESP32 else { // Codes: // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/wifi.html#wi-fi-reason-code display->drawString(x, y + FONT_HEIGHT_SMALL * 1, WiFi.disconnectReasonName(static_cast(getWifiDisconnectReason()))); } #else else { display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Unkown status: " + String(WiFi.status())); } #endif display->drawString(x, y + FONT_HEIGHT_SMALL * 2, "SSID: " + String(wifiName)); display->drawString(x, y + FONT_HEIGHT_SMALL * 3, "http://meshtastic.local"); /* Display a heartbeat pixel that blinks every time the frame is redrawn */ #ifdef SHOW_REDRAWS if (heartbeat) display->setPixel(0, 0); heartbeat = !heartbeat; #endif #endif } void DebugInfo::drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setFont(FONT_SMALL); // The coordinates define the left starting point of the text display->setTextAlignment(TEXT_ALIGN_LEFT); if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); display->setColor(BLACK); } char batStr[20]; if (powerStatus->getHasBattery()) { int batV = powerStatus->getBatteryVoltageMv() / 1000; int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; snprintf(batStr, sizeof(batStr), "B %01d.%02dV %3d%% %c%c", batV, batCv, powerStatus->getBatteryChargePercent(), powerStatus->getIsCharging() ? '+' : ' ', powerStatus->getHasUSB() ? 'U' : ' '); // Line 1 display->drawString(x, y, batStr); if (config.display.heading_bold) display->drawString(x + 1, y, batStr); } else { // Line 1 display->drawString(x, y, String("USB")); if (config.display.heading_bold) display->drawString(x + 1, y, String("USB")); } auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, true); display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode), y, mode); if (config.display.heading_bold) display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode) - 1, y, mode); // Line 2 uint32_t currentMillis = millis(); uint32_t seconds = currentMillis / 1000; uint32_t minutes = seconds / 60; uint32_t hours = minutes / 60; uint32_t days = hours / 24; // currentMillis %= 1000; // seconds %= 60; // minutes %= 60; // hours %= 24; display->setColor(WHITE); // Show uptime as days, hours, minutes OR seconds std::string uptime = screen->drawTimeDelta(days, hours, minutes, seconds); uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone if (rtc_sec > 0) { long hms = rtc_sec % SEC_PER_DAY; // hms += tz.tz_dsttime * SEC_PER_HOUR; // hms -= tz.tz_minuteswest * SEC_PER_MIN; // mod `hms` to ensure in positive range of [0...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 min = (hms % SEC_PER_HOUR) / SEC_PER_MIN; int sec = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN char timebuf[10]; snprintf(timebuf, sizeof(timebuf), " %02d:%02d:%02d", hour, min, sec); uptime += timebuf; } display->drawString(x, y + FONT_HEIGHT_SMALL * 1, uptime.c_str()); // Display Channel Utilization char chUtil[13]; snprintf(chUtil, sizeof(chUtil), "ChUtil %2.0f%%", airTime->channelUtilizationPercent()); display->drawString(x + SCREEN_WIDTH - display->getStringWidth(chUtil), y + FONT_HEIGHT_SMALL * 1, chUtil); #if HAS_GPS if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { // Line 3 if (config.display.gps_format != meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) // if DMS then don't draw altitude drawGPSAltitude(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); // Line 4 drawGPScoordinates(display, x, y + FONT_HEIGHT_SMALL * 3, gpsStatus); } else { drawGPSpowerstat(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); } #endif /* Display a heartbeat pixel that blinks every time the frame is redrawn */ #ifdef SHOW_REDRAWS if (heartbeat) display->setPixel(0, 0); heartbeat = !heartbeat; #endif } int Screen::handleStatusUpdate(const meshtastic::Status *arg) { // LOG_DEBUG("Screen got status update %d\n", 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; } int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) { if (showingNormalScreen) { // Outgoing message if (packet->from == 0) setFrames(FOCUS_PRESERVE); // Return to same frame (quietly hiding the rx text message frame) // Incoming message else setFrames(FOCUS_TEXTMESSAGE); // Focus on the new message } 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 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(); } return 0; } int Screen::handleInputEvent(const InputEvent *event) { #ifdef T_WATCH_S3 // 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; } // 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(); } } return 0; } int Screen::handleAdminMessage(const meshtastic_AdminMessage *arg) { // Note: only selected admin messages notify this observer // If you wish to handle a new type of message, you should modify AdminModule.cpp first 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; } } // namespace graphics #else graphics::Screen::Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY) {} #endif // HAS_SCREEN