mirror of
https://github.com/meshtastic/firmware.git
synced 2025-08-04 12:44:40 +00:00
1239 lines
51 KiB
C++
1239 lines
51 KiB
C++
#include "configuration.h"
|
||
#if HAS_SCREEN
|
||
#include "CompassRenderer.h"
|
||
#include "GPSStatus.h"
|
||
#include "NodeDB.h"
|
||
#include "NodeListRenderer.h"
|
||
#include "UIRenderer.h"
|
||
#include "airtime.h"
|
||
#include "configuration.h"
|
||
#include "gps/GeoCoord.h"
|
||
#include "graphics/Screen.h"
|
||
#include "graphics/ScreenFonts.h"
|
||
#include "graphics/SharedUIDisplay.h"
|
||
#include "graphics/images.h"
|
||
#include "main.h"
|
||
#include "target_specific.h"
|
||
#include <OLEDDisplay.h>
|
||
#include <RTC.h>
|
||
#include <cstring>
|
||
|
||
#if !MESHTASTIC_EXCLUDE_GPS
|
||
|
||
// External variables
|
||
extern graphics::Screen *screen;
|
||
|
||
namespace graphics
|
||
{
|
||
|
||
// GeoCoord object for coordinate conversions
|
||
extern GeoCoord geoCoord;
|
||
|
||
// Threshold values for the GPS lock accuracy bar display
|
||
extern uint32_t dopThresholds[5];
|
||
|
||
NodeNum UIRenderer::currentFavoriteNodeNum = 0;
|
||
|
||
// Draw GPS status summary
|
||
void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
||
{
|
||
// Draw satellite image
|
||
if (isHighResolution) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgSatellite_width, imgSatellite_height, imgSatellite, display);
|
||
} else {
|
||
display->drawXbm(x + 1, y + 1, imgSatellite_width, imgSatellite_height, imgSatellite);
|
||
}
|
||
char textString[10];
|
||
|
||
if (config.position.fixed_position) {
|
||
// GPS coordinates are currently fixed
|
||
snprintf(textString, sizeof(textString), "Fixed");
|
||
}
|
||
if (!gps->getIsConnected()) {
|
||
snprintf(textString, sizeof(textString), "No Lock");
|
||
}
|
||
if (!gps->getHasLock()) {
|
||
// Draw "No sats" to the right of the icon with slightly more gap
|
||
snprintf(textString, sizeof(textString), "No Sats");
|
||
} else {
|
||
snprintf(textString, sizeof(textString), "%u sats", gps->getNumSatellites());
|
||
}
|
||
if (isHighResolution) {
|
||
display->drawString(x + 18, y, textString);
|
||
} else {
|
||
display->drawString(x + 11, y, textString);
|
||
}
|
||
}
|
||
|
||
// Draw status when GPS is disabled or not present
|
||
void UIRenderer::drawGpsPowerStatus(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
||
{
|
||
const char *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 = display->getWidth() - display->getStringWidth(displayLine);
|
||
} else {
|
||
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "GPS not present"
|
||
: "GPS is disabled";
|
||
pos = (display->getWidth() - display->getStringWidth(displayLine)) / 2;
|
||
}
|
||
display->drawString(x + pos, y, displayLine);
|
||
}
|
||
|
||
void UIRenderer::drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
||
{
|
||
char displayLine[32];
|
||
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()));
|
||
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
|
||
snprintf(displayLine, sizeof(displayLine), "Altitude: %.0fft", geoCoord.getAltitude() * METERS_TO_FEET);
|
||
else
|
||
snprintf(displayLine, sizeof(displayLine), "Altitude: %.0im", geoCoord.getAltitude());
|
||
display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine);
|
||
}
|
||
}
|
||
|
||
// Draw GPS status coordinates
|
||
void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
||
{
|
||
auto gpsFormat = config.display.gps_format;
|
||
char displayLine[32];
|
||
|
||
if (!gps->getIsConnected() && !config.position.fixed_position) {
|
||
strcpy(displayLine, "No GPS present");
|
||
display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine);
|
||
} else if (!gps->getHasLock() && !config.position.fixed_position) {
|
||
strcpy(displayLine, "No GPS Lock");
|
||
display->drawString(x + (display->getWidth() - (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 + (display->getWidth() - (display->getStringWidth(coordinateLine))) / 2, y,
|
||
coordinateLine);
|
||
} else {
|
||
display->drawString(x + (display->getWidth() - (display->getStringWidth("Fixed GPS"))) / 2, y, "Fixed GPS");
|
||
}
|
||
} else {
|
||
display->drawString(x + (display->getWidth() - (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 + (display->getWidth() - (display->getStringWidth(latLine))) / 2, y - FONT_HEIGHT_SMALL * 1,
|
||
latLine);
|
||
display->drawString(x + (display->getWidth() - (display->getStringWidth(lonLine))) / 2, y, lonLine);
|
||
}
|
||
}
|
||
}
|
||
|
||
void UIRenderer::drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer,
|
||
const meshtastic::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 inside the battery image
|
||
for (int i = 1; i < 14; i++) {
|
||
imgBuffer[i] = 0x81;
|
||
}
|
||
|
||
// Fill with lightning or power bars
|
||
if (powerStatus->getIsCharging()) {
|
||
memcpy(imgBuffer + 3, lightning, 8);
|
||
} else {
|
||
for (int i = 0; i < 4; i++) {
|
||
if (powerStatus->getBatteryChargePercent() >= 25 * i)
|
||
memcpy(imgBuffer + 1 + (i * 3), powerBar, 3);
|
||
}
|
||
}
|
||
|
||
// Slightly more conservative scaling based on screen width
|
||
int scale = 1;
|
||
|
||
if (SCREEN_WIDTH >= 200)
|
||
scale = 2;
|
||
if (SCREEN_WIDTH >= 300)
|
||
scale = 2; // Do NOT go higher than 2
|
||
|
||
// Draw scaled battery image (16 columns × 8 rows)
|
||
for (int col = 0; col < 16; col++) {
|
||
uint8_t colBits = imgBuffer[col];
|
||
for (int row = 0; row < 8; row++) {
|
||
if (colBits & (1 << row)) {
|
||
display->fillRect(x + col * scale, y + row * scale, scale, scale);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw nodes status
|
||
void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus, int node_offset,
|
||
bool show_total, String additional_words)
|
||
{
|
||
char usersString[20];
|
||
int nodes_online = (nodeStatus->getNumOnline() > 0) ? nodeStatus->getNumOnline() + node_offset : 0;
|
||
|
||
snprintf(usersString, sizeof(usersString), "%d %s", nodes_online, additional_words.c_str());
|
||
|
||
if (show_total) {
|
||
int nodes_total = (nodeStatus->getNumTotal() > 0) ? nodeStatus->getNumTotal() + node_offset : 0;
|
||
snprintf(usersString, sizeof(usersString), "%d/%d %s", nodes_online, nodes_total, additional_words.c_str());
|
||
}
|
||
|
||
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
|
||
defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS)) && \
|
||
!defined(DISPLAY_FORCE_SMALL_FONTS)
|
||
|
||
if (isHighResolution) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display);
|
||
} else {
|
||
display->drawFastImage(x, y + 3, 8, 8, imgUser);
|
||
}
|
||
#else
|
||
if (isHighResolution) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display);
|
||
} else {
|
||
display->drawFastImage(x, y + 1, 8, 8, imgUser);
|
||
}
|
||
#endif
|
||
int string_offset = (isHighResolution) ? 9 : 0;
|
||
display->drawString(x + 10 + string_offset, y - 2, usersString);
|
||
}
|
||
|
||
// **********************
|
||
// * Favorite Node Info *
|
||
// **********************
|
||
void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
// --- Cache favorite nodes for the current frame only, to save computation ---
|
||
static std::vector<meshtastic_NodeInfoLite *> favoritedNodes;
|
||
static int prevFrame = -1;
|
||
|
||
// --- Only rebuild favorites list if we're on a new frame ---
|
||
if (state->currentFrame != prevFrame) {
|
||
prevFrame = state->currentFrame;
|
||
favoritedNodes.clear();
|
||
size_t total = nodeDB->getNumMeshNodes();
|
||
for (size_t i = 0; i < total; i++) {
|
||
meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
||
// Skip nulls and ourself
|
||
if (!n || n->num == nodeDB->getNodeNum())
|
||
continue;
|
||
if (n->is_favorite)
|
||
favoritedNodes.push_back(n);
|
||
}
|
||
// Keep a stable, consistent display order
|
||
std::sort(favoritedNodes.begin(), favoritedNodes.end(),
|
||
[](const meshtastic_NodeInfoLite *a, const meshtastic_NodeInfoLite *b) { return a->num < b->num; });
|
||
}
|
||
if (favoritedNodes.empty())
|
||
return;
|
||
|
||
// --- Only display if index is valid ---
|
||
int nodeIndex = state->currentFrame - (screen->frameCount - favoritedNodes.size());
|
||
if (nodeIndex < 0 || nodeIndex >= (int)favoritedNodes.size())
|
||
return;
|
||
|
||
meshtastic_NodeInfoLite *node = favoritedNodes[nodeIndex];
|
||
if (!node || node->num == nodeDB->getNodeNum() || !node->is_favorite)
|
||
return;
|
||
|
||
display->clear();
|
||
currentFavoriteNodeNum = node->num;
|
||
// === Create the shortName and title string ===
|
||
const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node";
|
||
char titlestr[32] = {0};
|
||
snprintf(titlestr, sizeof(titlestr), "Fav: %s", shortName);
|
||
|
||
// === Draw battery/time/mail header (common across screens) ===
|
||
graphics::drawCommonHeader(display, x, y, titlestr);
|
||
|
||
// ===== DYNAMIC ROW STACKING WITH YOUR MACROS =====
|
||
// 1. Each potential info row has a macro-defined Y position (not regular increments!).
|
||
// 2. Each row is only shown if it has valid data.
|
||
// 3. Each row "moves up" if previous are empty, so there are never any blank rows.
|
||
// 4. The first line is ALWAYS at your macro position; subsequent lines use the next available macro slot.
|
||
|
||
// List of available macro Y positions in order, from top to bottom.
|
||
int line = 1; // which slot to use next
|
||
|
||
// === 1. Long Name (always try to show first) ===
|
||
const char *username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr;
|
||
if (username && line < 5) {
|
||
// Print node's long name (e.g. "Backpack Node")
|
||
display->drawString(x, getTextPositions(display)[line++], username);
|
||
}
|
||
|
||
// === 2. Signal and Hops (combined on one line, if available) ===
|
||
// If both are present: "Sig: 97% [2hops]"
|
||
// If only one: show only that one
|
||
char signalHopsStr[32] = "";
|
||
bool haveSignal = false;
|
||
int percentSignal = clamp((int)((node->snr + 10) * 5), 0, 100);
|
||
|
||
// Always use "Sig" for the label
|
||
const char *signalLabel = " Sig";
|
||
|
||
// --- Build the Signal/Hops line ---
|
||
// If SNR looks reasonable, show signal
|
||
if ((int)((node->snr + 10) * 5) >= 0 && node->snr > -100) {
|
||
snprintf(signalHopsStr, sizeof(signalHopsStr), "%s: %d%%", signalLabel, percentSignal);
|
||
haveSignal = true;
|
||
}
|
||
// If hops is valid (>0), show right after signal
|
||
if (node->hops_away > 0) {
|
||
size_t len = strlen(signalHopsStr);
|
||
// Decide between "1 Hop" and "N Hops"
|
||
if (haveSignal) {
|
||
snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [%d %s]", node->hops_away,
|
||
(node->hops_away == 1 ? "Hop" : "Hops"));
|
||
} else {
|
||
snprintf(signalHopsStr, sizeof(signalHopsStr), "[%d %s]", node->hops_away, (node->hops_away == 1 ? "Hop" : "Hops"));
|
||
}
|
||
}
|
||
if (signalHopsStr[0] && line < 5) {
|
||
display->drawString(x, getTextPositions(display)[line++], signalHopsStr);
|
||
}
|
||
|
||
// === 3. Heard (last seen, skip if node never seen) ===
|
||
char seenStr[20] = "";
|
||
uint32_t seconds = sinceLastSeen(node);
|
||
if (seconds != 0 && seconds != UINT32_MAX) {
|
||
uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
|
||
// Format as "Heard: Xm ago", "Heard: Xh ago", or "Heard: Xd ago"
|
||
snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard: ?" : " Heard: %d%c ago"),
|
||
(days ? days
|
||
: hours ? hours
|
||
: minutes),
|
||
(days ? 'd'
|
||
: hours ? 'h'
|
||
: 'm'));
|
||
}
|
||
if (seenStr[0] && line < 5) {
|
||
display->drawString(x, getTextPositions(display)[line++], seenStr);
|
||
}
|
||
|
||
// === 4. Uptime (only show if metric is present) ===
|
||
char uptimeStr[32] = "";
|
||
if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) {
|
||
uint32_t uptime = node->device_metrics.uptime_seconds;
|
||
uint32_t days = uptime / 86400;
|
||
uint32_t hours = (uptime % 86400) / 3600;
|
||
uint32_t mins = (uptime % 3600) / 60;
|
||
// Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m"
|
||
if (days)
|
||
snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %ud %uh", days, hours);
|
||
else if (hours)
|
||
snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %uh %um", hours, mins);
|
||
else
|
||
snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %um", mins);
|
||
}
|
||
if (uptimeStr[0] && line < 5) {
|
||
display->drawString(x, getTextPositions(display)[line++], uptimeStr);
|
||
}
|
||
|
||
// === 5. Distance (only if both nodes have GPS position) ===
|
||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||
char distStr[24] = ""; // Make buffer big enough for any string
|
||
bool haveDistance = false;
|
||
|
||
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
|
||
double lat1 = ourNode->position.latitude_i * 1e-7;
|
||
double lon1 = ourNode->position.longitude_i * 1e-7;
|
||
double lat2 = node->position.latitude_i * 1e-7;
|
||
double lon2 = node->position.longitude_i * 1e-7;
|
||
double earthRadiusKm = 6371.0;
|
||
double dLat = (lat2 - lat1) * DEG_TO_RAD;
|
||
double dLon = (lon2 - lon1) * DEG_TO_RAD;
|
||
double a =
|
||
sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2);
|
||
double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||
double distanceKm = earthRadiusKm * c;
|
||
|
||
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
|
||
double miles = distanceKm * 0.621371;
|
||
if (miles < 0.1) {
|
||
int feet = (int)(miles * 5280);
|
||
if (feet > 0 && feet < 1000) {
|
||
snprintf(distStr, sizeof(distStr), " Distance: %dft", feet);
|
||
haveDistance = true;
|
||
} else if (feet >= 1000) {
|
||
snprintf(distStr, sizeof(distStr), " Distance: ¼mi");
|
||
haveDistance = true;
|
||
}
|
||
} else {
|
||
int roundedMiles = (int)(miles + 0.5);
|
||
if (roundedMiles > 0 && roundedMiles < 1000) {
|
||
snprintf(distStr, sizeof(distStr), " Distance: %dmi", roundedMiles);
|
||
haveDistance = true;
|
||
}
|
||
}
|
||
} else {
|
||
if (distanceKm < 1.0) {
|
||
int meters = (int)(distanceKm * 1000);
|
||
if (meters > 0 && meters < 1000) {
|
||
snprintf(distStr, sizeof(distStr), " Distance: %dm", meters);
|
||
haveDistance = true;
|
||
} else if (meters >= 1000) {
|
||
snprintf(distStr, sizeof(distStr), " Distance: 1km");
|
||
haveDistance = true;
|
||
}
|
||
} else {
|
||
int km = (int)(distanceKm + 0.5);
|
||
if (km > 0 && km < 1000) {
|
||
snprintf(distStr, sizeof(distStr), " Distance: %dkm", km);
|
||
haveDistance = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Only display if we actually have a value!
|
||
if (haveDistance && distStr[0] && line < 5) {
|
||
display->drawString(x, getTextPositions(display)[line++], distStr);
|
||
}
|
||
|
||
// --- Compass Rendering: landscape (wide) screens use the original side-aligned logic ---
|
||
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
||
bool showCompass = false;
|
||
if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
|
||
showCompass = true;
|
||
}
|
||
if (showCompass) {
|
||
const int16_t topY = getTextPositions(display)[1];
|
||
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1);
|
||
const int16_t usableHeight = bottomY - topY - 5;
|
||
int16_t compassRadius = usableHeight / 2;
|
||
if (compassRadius < 8)
|
||
compassRadius = 8;
|
||
const int16_t compassDiam = compassRadius * 2;
|
||
const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8;
|
||
const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
||
|
||
const auto &op = ourNode->position;
|
||
float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
|
||
: screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
|
||
|
||
const auto &p = node->position;
|
||
/* unused
|
||
float d =
|
||
GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i));
|
||
*/
|
||
float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
||
if (!config.display.compass_north_top)
|
||
bearing -= myHeading;
|
||
|
||
display->drawCircle(compassX, compassY, compassRadius);
|
||
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing);
|
||
}
|
||
// else show nothing
|
||
} else {
|
||
// Portrait or square: put compass at the bottom and centered, scaled to fit available space
|
||
bool showCompass = false;
|
||
if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
|
||
showCompass = true;
|
||
}
|
||
if (showCompass) {
|
||
int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2)
|
||
: getTextPositions(display)[1];
|
||
const int margin = 4;
|
||
// --------- PATCH FOR EINK NAV BAR (ONLY CHANGE BELOW) -----------
|
||
#if defined(USE_EINK)
|
||
const int iconSize = (isHighResolution) ? 16 : 8;
|
||
const int navBarHeight = iconSize + 6;
|
||
#else
|
||
const int navBarHeight = 0;
|
||
#endif
|
||
int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin;
|
||
// --------- END PATCH FOR EINK NAV BAR -----------
|
||
|
||
if (availableHeight < FONT_HEIGHT_SMALL * 2)
|
||
return;
|
||
|
||
int compassRadius = availableHeight / 2;
|
||
if (compassRadius < 8)
|
||
compassRadius = 8;
|
||
if (compassRadius * 2 > SCREEN_WIDTH - 16)
|
||
compassRadius = (SCREEN_WIDTH - 16) / 2;
|
||
|
||
int compassX = x + SCREEN_WIDTH / 2;
|
||
int compassY = yBelowContent + availableHeight / 2;
|
||
|
||
const auto &op = ourNode->position;
|
||
float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
|
||
: screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
|
||
graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
||
|
||
const auto &p = node->position;
|
||
/* unused
|
||
float d =
|
||
GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i));
|
||
*/
|
||
float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
||
if (!config.display.compass_north_top)
|
||
bearing -= myHeading;
|
||
graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
|
||
|
||
display->drawCircle(compassX, compassY, compassRadius);
|
||
}
|
||
// else show nothing
|
||
}
|
||
}
|
||
|
||
// ****************************
|
||
// * Device Focused Screen *
|
||
// ****************************
|
||
void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
display->clear();
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||
display->setFont(FONT_SMALL);
|
||
int line = 1;
|
||
|
||
// === Header ===
|
||
graphics::drawCommonHeader(display, x, y, "");
|
||
|
||
// === Content below header ===
|
||
|
||
// Determine if we need to show 4 or 5 rows on the screen
|
||
int rows = 4;
|
||
if (!config.bluetooth.enabled) {
|
||
rows = 5;
|
||
}
|
||
|
||
// === First Row: Region / Channel Utilization and Uptime ===
|
||
bool origBold = config.display.heading_bold;
|
||
config.display.heading_bold = false;
|
||
|
||
// Display Region and Channel Utilization
|
||
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
||
|
||
char uptimeStr[32] = "";
|
||
uint32_t uptime = millis() / 1000;
|
||
uint32_t days = uptime / 86400;
|
||
uint32_t hours = (uptime % 86400) / 3600;
|
||
uint32_t mins = (uptime % 3600) / 60;
|
||
// Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m"
|
||
if (days)
|
||
snprintf(uptimeStr, sizeof(uptimeStr), "Up: %ud %uh", days, hours);
|
||
else if (hours)
|
||
snprintf(uptimeStr, sizeof(uptimeStr), "Up: %uh %um", hours, mins);
|
||
else
|
||
snprintf(uptimeStr, sizeof(uptimeStr), "Up: %um", mins);
|
||
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
|
||
|
||
// === Second Row: Satellites and Voltage ===
|
||
config.display.heading_bold = false;
|
||
|
||
#if HAS_GPS
|
||
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
||
const char *displayLine;
|
||
if (config.position.fixed_position) {
|
||
displayLine = "Fixed GPS";
|
||
} else {
|
||
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off";
|
||
}
|
||
int yOffset = (isHighResolution) ? 3 : 1;
|
||
if (isHighResolution) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, getTextPositions(display)[line] + yOffset - 5, imgSatellite_width,
|
||
imgSatellite_height, imgSatellite, display);
|
||
} else {
|
||
display->drawXbm(x + 1, getTextPositions(display)[line] + yOffset, imgSatellite_width, imgSatellite_height,
|
||
imgSatellite);
|
||
}
|
||
int xOffset = (isHighResolution) ? 6 : 0;
|
||
display->drawString(x + 11 + xOffset, getTextPositions(display)[line], displayLine);
|
||
} else {
|
||
UIRenderer::drawGps(display, 0, getTextPositions(display)[line], gpsStatus);
|
||
}
|
||
#endif
|
||
|
||
if (powerStatus->getHasBattery()) {
|
||
char batStr[20];
|
||
int batV = powerStatus->getBatteryVoltageMv() / 1000;
|
||
int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10;
|
||
snprintf(batStr, sizeof(batStr), "%01d.%02dV", batV, batCv);
|
||
display->drawString(x + SCREEN_WIDTH - display->getStringWidth(batStr), getTextPositions(display)[line++], batStr);
|
||
} else {
|
||
display->drawString(x + SCREEN_WIDTH - display->getStringWidth("USB"), getTextPositions(display)[line++], "USB");
|
||
}
|
||
|
||
config.display.heading_bold = origBold;
|
||
|
||
// === Third Row: Channel Utilization Bluetooth Off (Only If Actually Off) ===
|
||
const char *chUtil = "ChUtil:";
|
||
char chUtilPercentage[10];
|
||
snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent());
|
||
|
||
int chUtil_x = (isHighResolution) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5;
|
||
int chUtil_y = getTextPositions(display)[line] + 3;
|
||
|
||
int chutil_bar_width = (isHighResolution) ? 100 : 50;
|
||
if (!config.bluetooth.enabled) {
|
||
chutil_bar_width = (isHighResolution) ? 80 : 40;
|
||
}
|
||
int chutil_bar_height = (isHighResolution) ? 12 : 7;
|
||
int extraoffset = (isHighResolution) ? 6 : 3;
|
||
if (!config.bluetooth.enabled) {
|
||
extraoffset = (isHighResolution) ? 6 : 1;
|
||
}
|
||
int chutil_percent = airTime->channelUtilizationPercent();
|
||
|
||
int centerofscreen = SCREEN_WIDTH / 2;
|
||
int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
|
||
int starting_position = centerofscreen - total_line_content_width;
|
||
if (!config.bluetooth.enabled) {
|
||
starting_position = 0;
|
||
}
|
||
|
||
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
|
||
|
||
// Force 56% or higher to show a full 100% bar, text would still show related percent.
|
||
if (chutil_percent >= 61) {
|
||
chutil_percent = 100;
|
||
}
|
||
|
||
// Weighting for nonlinear segments
|
||
float milestone1 = 25;
|
||
float milestone2 = 40;
|
||
float weight1 = 0.45; // Weight for 0–25%
|
||
float weight2 = 0.35; // Weight for 25–40%
|
||
float weight3 = 0.20; // Weight for 40–100%
|
||
float totalWeight = weight1 + weight2 + weight3;
|
||
|
||
int seg1 = chutil_bar_width * (weight1 / totalWeight);
|
||
int seg2 = chutil_bar_width * (weight2 / totalWeight);
|
||
int seg3 = chutil_bar_width * (weight3 / totalWeight);
|
||
|
||
int fillRight = 0;
|
||
|
||
if (chutil_percent <= milestone1) {
|
||
fillRight = (seg1 * (chutil_percent / milestone1));
|
||
} else if (chutil_percent <= milestone2) {
|
||
fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1)));
|
||
} else {
|
||
fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2)));
|
||
}
|
||
|
||
// Draw outline
|
||
display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height);
|
||
|
||
// Fill progress
|
||
if (fillRight > 0) {
|
||
display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height);
|
||
}
|
||
|
||
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line],
|
||
chUtilPercentage);
|
||
|
||
if (!config.bluetooth.enabled) {
|
||
display->drawString(SCREEN_WIDTH - display->getStringWidth("BT off"), getTextPositions(display)[line], "BT off");
|
||
}
|
||
|
||
line += 1;
|
||
|
||
// === Fourth & Fifth Rows: Node Identity ===
|
||
int textWidth = 0;
|
||
int nameX = 0;
|
||
int yOffset = (isHighResolution) ? 0 : 5;
|
||
const char *longName = nullptr;
|
||
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||
if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) {
|
||
longName = ourNode->user.long_name;
|
||
}
|
||
uint8_t dmac[6];
|
||
char shortnameble[35];
|
||
getMacAddr(dmac);
|
||
snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]);
|
||
snprintf(shortnameble, sizeof(shortnameble), "%s",
|
||
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||
|
||
char combinedName[50];
|
||
snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortnameble);
|
||
if (SCREEN_WIDTH - (display->getStringWidth(longName) + display->getStringWidth(shortnameble)) > 10) {
|
||
size_t len = strlen(combinedName);
|
||
if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) {
|
||
combinedName[len - 3] = '\0'; // Remove the last three characters
|
||
}
|
||
textWidth = display->getStringWidth(combinedName);
|
||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||
display->drawString(
|
||
nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, combinedName);
|
||
} else {
|
||
// === LongName Centered ===
|
||
textWidth = display->getStringWidth(longName);
|
||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||
display->drawString(nameX, getTextPositions(display)[line++], longName);
|
||
|
||
// === ShortName Centered ===
|
||
textWidth = display->getStringWidth(shortnameble);
|
||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
|
||
}
|
||
}
|
||
|
||
// Start Functions to write date/time to the screen
|
||
// Helper function to check if a year is a leap year
|
||
bool isLeapYear(int year)
|
||
{
|
||
return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
|
||
}
|
||
|
||
// Array of days in each month (non-leap year)
|
||
const int daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
||
|
||
// Fills the buffer with a formatted date/time string and returns pixel width
|
||
int UIRenderer::formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLEDDisplay *display, bool includeTime)
|
||
{
|
||
int sec = rtc_sec % 60;
|
||
rtc_sec /= 60;
|
||
int min = rtc_sec % 60;
|
||
rtc_sec /= 60;
|
||
int hour = rtc_sec % 24;
|
||
rtc_sec /= 24;
|
||
|
||
int year = 1970;
|
||
while (true) {
|
||
int daysInYear = isLeapYear(year) ? 366 : 365;
|
||
if (rtc_sec >= (uint32_t)daysInYear) {
|
||
rtc_sec -= daysInYear;
|
||
year++;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
int month = 0;
|
||
while (month < 12) {
|
||
int dim = daysInMonth[month];
|
||
if (month == 1 && isLeapYear(year))
|
||
dim++;
|
||
if (rtc_sec >= (uint32_t)dim) {
|
||
rtc_sec -= dim;
|
||
month++;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
int day = rtc_sec + 1;
|
||
|
||
if (includeTime) {
|
||
snprintf(buf, bufSize, "%04d-%02d-%02d %02d:%02d:%02d", year, month + 1, day, hour, min, sec);
|
||
} else {
|
||
snprintf(buf, bufSize, "%04d-%02d-%02d", year, month + 1, day);
|
||
}
|
||
|
||
return display->getStringWidth(buf);
|
||
}
|
||
|
||
// Check if the display can render a string (detect special chars; emoji)
|
||
bool UIRenderer::haveGlyphs(const char *str)
|
||
{
|
||
#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS)
|
||
// 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", have);
|
||
return have;
|
||
}
|
||
|
||
#ifdef USE_EINK
|
||
/// Used on eink displays while in deep sleep
|
||
void UIRenderer::drawDeepSleepFrame(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("Draw deep sleep screen");
|
||
|
||
// Display displayStr on the screen
|
||
graphics::UIRenderer::drawIconScreen("Sleeping", display, state, x, y);
|
||
}
|
||
|
||
/// Used on eink displays when screen updates are paused
|
||
void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state)
|
||
{
|
||
LOG_DEBUG("Draw screensaver overlay");
|
||
|
||
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
|
||
|
||
/**
|
||
* Draw the icon with extra info printed around the corners
|
||
*/
|
||
void UIRenderer::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);
|
||
const char *title = "meshtastic.org";
|
||
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),
|
||
graphics::UIRenderer::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
|
||
}
|
||
|
||
// ****************************
|
||
// * My Position Screen *
|
||
// ****************************
|
||
void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
display->clear();
|
||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||
display->setFont(FONT_SMALL);
|
||
int line = 1;
|
||
|
||
// === Set Title
|
||
const char *titleStr = "Position";
|
||
|
||
// === Header ===
|
||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||
|
||
// === First Row: My Location ===
|
||
#if HAS_GPS
|
||
bool origBold = config.display.heading_bold;
|
||
config.display.heading_bold = false;
|
||
|
||
const char *displayLine = ""; // Initialize to empty string by default
|
||
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
||
if (config.position.fixed_position) {
|
||
displayLine = "Fixed GPS";
|
||
} else {
|
||
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off";
|
||
}
|
||
int yOffset = (isHighResolution) ? 3 : 1;
|
||
if (isHighResolution) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, getTextPositions(display)[line] + yOffset - 5, imgSatellite_width,
|
||
imgSatellite_height, imgSatellite, display);
|
||
} else {
|
||
display->drawXbm(x + 1, getTextPositions(display)[line] + yOffset, imgSatellite_width, imgSatellite_height,
|
||
imgSatellite);
|
||
}
|
||
int xOffset = (isHighResolution) ? 6 : 0;
|
||
display->drawString(x + 11 + xOffset, getTextPositions(display)[line++], displayLine);
|
||
} else {
|
||
UIRenderer::drawGps(display, 0, getTextPositions(display)[line++], gpsStatus);
|
||
}
|
||
|
||
config.display.heading_bold = origBold;
|
||
|
||
// === Update GeoCoord ===
|
||
geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()),
|
||
int32_t(gpsStatus->getAltitude()));
|
||
|
||
// === Determine Compass Heading ===
|
||
float heading;
|
||
bool validHeading = false;
|
||
|
||
if (screen->hasHeading()) {
|
||
heading = radians(screen->getHeading());
|
||
validHeading = true;
|
||
} else {
|
||
heading = screen->estimatedHeading(geoCoord.getLatitude() * 1e-7, geoCoord.getLongitude() * 1e-7);
|
||
validHeading = !isnan(heading);
|
||
}
|
||
|
||
// If GPS is off, no need to display these parts
|
||
if (strcmp(displayLine, "GPS off") != 0 && strcmp(displayLine, "No GPS") != 0) {
|
||
|
||
// === Second Row: Date ===
|
||
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true);
|
||
char datetimeStr[25];
|
||
bool showTime = false; // set to true for full datetime
|
||
UIRenderer::formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display, showTime);
|
||
char fullLine[40];
|
||
snprintf(fullLine, sizeof(fullLine), " Date: %s", datetimeStr);
|
||
display->drawString(0, getTextPositions(display)[line++], fullLine);
|
||
|
||
// === Third Row: Latitude ===
|
||
char latStr[32];
|
||
snprintf(latStr, sizeof(latStr), " Lat: %.5f", geoCoord.getLatitude() * 1e-7);
|
||
display->drawString(x, getTextPositions(display)[line++], latStr);
|
||
|
||
// === Fourth Row: Longitude ===
|
||
char lonStr[32];
|
||
snprintf(lonStr, sizeof(lonStr), " Lon: %.5f", geoCoord.getLongitude() * 1e-7);
|
||
display->drawString(x, getTextPositions(display)[line++], lonStr);
|
||
|
||
// === Fifth Row: Altitude ===
|
||
char DisplayLineTwo[32] = {0};
|
||
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
|
||
snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), " Alt: %.0fft", geoCoord.getAltitude() * METERS_TO_FEET);
|
||
} else {
|
||
snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), " Alt: %.0im", geoCoord.getAltitude());
|
||
}
|
||
display->drawString(x, getTextPositions(display)[line++], DisplayLineTwo);
|
||
}
|
||
|
||
// === Draw Compass if heading is valid ===
|
||
if (validHeading) {
|
||
// --- Compass Rendering: landscape (wide) screens use original side-aligned logic ---
|
||
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
||
const int16_t topY = getTextPositions(display)[1];
|
||
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height
|
||
const int16_t usableHeight = bottomY - topY - 5;
|
||
|
||
int16_t compassRadius = usableHeight / 2;
|
||
if (compassRadius < 8)
|
||
compassRadius = 8;
|
||
const int16_t compassDiam = compassRadius * 2;
|
||
const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8;
|
||
|
||
// Center vertically and nudge down slightly to keep "N" clear of header
|
||
const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
||
|
||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading);
|
||
display->drawCircle(compassX, compassY, compassRadius);
|
||
|
||
// "N" label
|
||
float northAngle = -heading;
|
||
float radius = compassRadius;
|
||
int16_t nX = compassX + (radius - 1) * sin(northAngle);
|
||
int16_t nY = compassY - (radius - 1) * cos(northAngle);
|
||
int16_t nLabelWidth = display->getStringWidth("N") + 2;
|
||
int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
|
||
|
||
display->setColor(BLACK);
|
||
display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
|
||
display->setColor(WHITE);
|
||
display->setFont(FONT_SMALL);
|
||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||
display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
|
||
} else {
|
||
// Portrait or square: put compass at the bottom and centered, scaled to fit available space
|
||
// For E-Ink screens, account for navigation bar at the bottom!
|
||
int yBelowContent = getTextPositions(display)[5] + FONT_HEIGHT_SMALL + 2;
|
||
const int margin = 4;
|
||
int availableHeight =
|
||
#if defined(USE_EINK)
|
||
SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink
|
||
#else
|
||
SCREEN_HEIGHT - yBelowContent - margin;
|
||
#endif
|
||
|
||
if (availableHeight < FONT_HEIGHT_SMALL * 2)
|
||
return;
|
||
|
||
int compassRadius = availableHeight / 2;
|
||
if (compassRadius < 8)
|
||
compassRadius = 8;
|
||
if (compassRadius * 2 > SCREEN_WIDTH - 16)
|
||
compassRadius = (SCREEN_WIDTH - 16) / 2;
|
||
|
||
int compassX = x + SCREEN_WIDTH / 2;
|
||
int compassY = yBelowContent + availableHeight / 2;
|
||
|
||
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading);
|
||
display->drawCircle(compassX, compassY, compassRadius);
|
||
|
||
// "N" label
|
||
float northAngle = -heading;
|
||
float radius = compassRadius;
|
||
int16_t nX = compassX + (radius - 1) * sin(northAngle);
|
||
int16_t nY = compassY - (radius - 1) * cos(northAngle);
|
||
int16_t nLabelWidth = display->getStringWidth("N") + 2;
|
||
int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
|
||
|
||
display->setColor(BLACK);
|
||
display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
|
||
display->setColor(WHITE);
|
||
display->setFont(FONT_SMALL);
|
||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||
display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
#ifdef USERPREFS_OEM_TEXT
|
||
|
||
void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||
{
|
||
static const uint8_t xbm[] = USERPREFS_OEM_IMAGE_DATA;
|
||
display->drawXbm(x + (SCREEN_WIDTH - USERPREFS_OEM_IMAGE_WIDTH) / 2,
|
||
y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - USERPREFS_OEM_IMAGE_HEIGHT) / 2 + 2, USERPREFS_OEM_IMAGE_WIDTH,
|
||
USERPREFS_OEM_IMAGE_HEIGHT, xbm);
|
||
|
||
switch (USERPREFS_OEM_FONT_SIZE) {
|
||
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 = USERPREFS_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
|
||
}
|
||
|
||
void UIRenderer::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);
|
||
}
|
||
|
||
#endif
|
||
|
||
// Function overlay for showing mute/buzzer modifiers etc.
|
||
void UIRenderer::drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state)
|
||
{
|
||
// LOG_DEBUG("Draw function overlay");
|
||
if (functionSymbol.begin() != functionSymbol.end()) {
|
||
char buf[64];
|
||
display->setFont(FONT_SMALL);
|
||
snprintf(buf, sizeof(buf), "%s", functionSymbolString.c_str());
|
||
display->drawString(SCREEN_WIDTH - display->getStringWidth(buf), SCREEN_HEIGHT - FONT_HEIGHT_SMALL, buf);
|
||
}
|
||
}
|
||
|
||
// Navigation bar overlay implementation
|
||
static int8_t lastFrameIndex = -1;
|
||
static uint32_t lastFrameChangeTime = 0;
|
||
constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
|
||
|
||
void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state)
|
||
{
|
||
int currentFrame = state->currentFrame;
|
||
|
||
// Detect frame change and record time
|
||
if (currentFrame != lastFrameIndex) {
|
||
lastFrameIndex = currentFrame;
|
||
lastFrameChangeTime = millis();
|
||
}
|
||
|
||
const int iconSize = isHighResolution ? 16 : 8;
|
||
const int spacing = isHighResolution ? 8 : 4;
|
||
const int bigOffset = isHighResolution ? 1 : 0;
|
||
|
||
const size_t totalIcons = screen->indicatorIcons.size();
|
||
if (totalIcons == 0)
|
||
return;
|
||
|
||
const size_t iconsPerPage = (SCREEN_WIDTH + spacing) / (iconSize + spacing);
|
||
const size_t currentPage = currentFrame / iconsPerPage;
|
||
const size_t pageStart = currentPage * iconsPerPage;
|
||
const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons);
|
||
|
||
const int totalWidth = (pageEnd - pageStart) * iconSize + (pageEnd - pageStart - 1) * spacing;
|
||
const int xStart = (SCREEN_WIDTH - totalWidth) / 2;
|
||
|
||
// Only show bar briefly after switching frames (unless on E-Ink)
|
||
#if defined(USE_EINK)
|
||
int y = SCREEN_HEIGHT - iconSize - 1;
|
||
#else
|
||
int y = SCREEN_HEIGHT - iconSize - 1;
|
||
if (millis() - lastFrameChangeTime > ICON_DISPLAY_DURATION_MS) {
|
||
y = SCREEN_HEIGHT;
|
||
}
|
||
#endif
|
||
|
||
// Pre-calculate bounding rect
|
||
const int rectX = xStart - 2 - bigOffset;
|
||
const int rectWidth = totalWidth + 4 + (bigOffset * 2);
|
||
const int rectHeight = iconSize + 6;
|
||
|
||
// Clear background and draw border
|
||
display->setColor(BLACK);
|
||
display->fillRect(rectX + 1, y - 2, rectWidth - 2, rectHeight - 2);
|
||
display->setColor(WHITE);
|
||
display->drawRect(rectX, y - 2, rectWidth, rectHeight);
|
||
|
||
// Icon drawing loop for the current page
|
||
for (size_t i = pageStart; i < pageEnd; ++i) {
|
||
const uint8_t *icon = screen->indicatorIcons[i];
|
||
const int x = xStart + (i - pageStart) * (iconSize + spacing);
|
||
const bool isActive = (i == static_cast<size_t>(currentFrame));
|
||
|
||
if (isActive) {
|
||
display->setColor(WHITE);
|
||
display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4);
|
||
display->setColor(BLACK);
|
||
}
|
||
|
||
if (isHighResolution) {
|
||
NodeListRenderer::drawScaledXBitmap16x16(x, y, 8, 8, icon, display);
|
||
} else {
|
||
display->drawXbm(x, y, iconSize, iconSize, icon);
|
||
}
|
||
|
||
if (isActive) {
|
||
display->setColor(WHITE);
|
||
}
|
||
}
|
||
|
||
// Knock the corners off the square
|
||
display->setColor(BLACK);
|
||
display->drawRect(rectX, y - 2, 1, 1);
|
||
display->drawRect(rectX + rectWidth - 1, y - 2, 1, 1);
|
||
display->setColor(WHITE);
|
||
}
|
||
|
||
void UIRenderer::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);
|
||
}
|
||
|
||
std::string UIRenderer::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;
|
||
}
|
||
|
||
} // namespace graphics
|
||
|
||
#endif // !MESHTASTIC_EXCLUDE_GPS
|
||
#endif // HAS_SCREEN
|