From 97983d8014037707b66b5b9529e782b2d34d13c5 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:01:26 -0500 Subject: [PATCH] BaseUI: Favorite Screen Signal Quality improvement (#9566) * Favorite Signal Quality improvement * Show Voltage if node shares it. * Trunk Fix * Change Favorite tittle to encase name with Asterisks * Add Pluggin In condition for Battery Line * Adjust getUptimeStr Prefixes * Create isAPIConnected for SharedCommon usage * Correct leftSideSpacing to account for isAPIConnected --------- Co-authored-by: Jason P --- src/graphics/SharedUIDisplay.cpp | 50 +++--- src/graphics/SharedUIDisplay.h | 14 ++ src/graphics/TimeFormatters.cpp | 12 +- src/graphics/draw/DebugRenderer.cpp | 2 +- src/graphics/draw/UIRenderer.cpp | 244 ++++++++++++++++++++++++---- src/graphics/images.h | 6 + 6 files changed, 260 insertions(+), 68 deletions(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index ab21b2a03..b86f3e32c 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -427,39 +427,33 @@ const int *getTextPositions(OLEDDisplay *display) // ************************* void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) { - bool drawConnectionState = false; - if (service->api_state == service->STATE_BLE || service->api_state == service->STATE_WIFI || - service->api_state == service->STATE_SERIAL || service->api_state == service->STATE_PACKET || - service->api_state == service->STATE_HTTP || service->api_state == service->STATE_ETH) { - drawConnectionState = true; - } + if (!isAPIConnected(service->api_state)) + return; - if (drawConnectionState) { - const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; - display->setColor(BLACK); - display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale), - (connection_icon_height * scale) + (2 * scale)); - display->setColor(WHITE); - if (currentResolution == ScreenResolution::High) { - const int bytesPerRow = (connection_icon_width + 7) / 8; - int iconX = 0; - int iconY = SCREEN_HEIGHT - (connection_icon_height * 2); + const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; + display->setColor(BLACK); + display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale), + (connection_icon_height * scale) + (2 * scale)); + display->setColor(WHITE); + if (currentResolution == ScreenResolution::High) { + const int bytesPerRow = (connection_icon_width + 7) / 8; + int iconX = 0; + int iconY = SCREEN_HEIGHT - (connection_icon_height * 2); - for (int yy = 0; yy < connection_icon_height; ++yy) { - const uint8_t *rowPtr = connection_icon + yy * bytesPerRow; - for (int xx = 0; xx < connection_icon_width; ++xx) { - const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3)); - const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first - if (byteVal & bitMask) { - display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, scale); - } + for (int yy = 0; yy < connection_icon_height; ++yy) { + const uint8_t *rowPtr = connection_icon + yy * bytesPerRow; + for (int xx = 0; xx < connection_icon_width; ++xx) { + const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3)); + const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first + if (byteVal & bitMask) { + display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, scale); } } - - } else { - display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height, - connection_icon); } + + } else { + display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height, + connection_icon); } } diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h index a8ecdfada..35e767056 100644 --- a/src/graphics/SharedUIDisplay.h +++ b/src/graphics/SharedUIDisplay.h @@ -63,4 +63,18 @@ bool isAllowedPunctuation(char c); std::string sanitizeString(const std::string &input); +static inline bool isAPIConnected(uint8_t state) +{ + static constexpr bool connectedStates[] = { + /* STATE_NONE */ false, + /* STATE_BLE */ true, + /* STATE_WIFI */ true, + /* STATE_SERIAL */ true, + /* STATE_PACKET */ true, + /* STATE_HTTP */ true, + /* STATE_ETH */ true, + }; + return state < sizeof(connectedStates) ? connectedStates[state] : false; +} + } // namespace graphics diff --git a/src/graphics/TimeFormatters.cpp b/src/graphics/TimeFormatters.cpp index 0a1c23341..02450efa3 100644 --- a/src/graphics/TimeFormatters.cpp +++ b/src/graphics/TimeFormatters.cpp @@ -110,14 +110,14 @@ void getUptimeStr(uint32_t uptimeMillis, const char *prefix, char *uptimeStr, ui uint32_t secs = (uptimeMillis % 60000) / 1000; if (days) { - snprintf(uptimeStr, maxLength, "%s: %ud %uh", prefix, days, hours); + snprintf(uptimeStr, maxLength, "%s%ud %uh", prefix, days, hours); } else if (hours) { - snprintf(uptimeStr, maxLength, "%s: %uh %um", prefix, hours, mins); + snprintf(uptimeStr, maxLength, "%s%uh %um", prefix, hours, mins); } else if (!includeSecs) { - snprintf(uptimeStr, maxLength, "%s: %um", prefix, mins); + snprintf(uptimeStr, maxLength, "%s%um", prefix, mins); } else if (mins) { - snprintf(uptimeStr, maxLength, "%s: %um %us", prefix, mins, secs); + snprintf(uptimeStr, maxLength, "%s%um %us", prefix, mins, secs); } else { - snprintf(uptimeStr, maxLength, "%s: %us", prefix, secs); + snprintf(uptimeStr, maxLength, "%s%us", prefix, secs); } -} +} \ No newline at end of file diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 2dca38d66..2069c71ec 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -663,7 +663,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x if (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5)) { // Only show uptime if the screen can show it char uptimeStr[32] = ""; - getUptimeStr(millis(), "Up", uptimeStr, sizeof(uptimeStr)); + getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr)); textWidth = display->getStringWidth(uptimeStr); nameX = (SCREEN_WIDTH - textWidth) / 2; display->drawString(nameX, getTextPositions(display)[line++], uptimeStr); diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 7ce9d5afe..922ca1028 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -2,6 +2,7 @@ #if HAS_SCREEN #include "CompassRenderer.h" #include "GPSStatus.h" +#include "MeshService.h" #include "NodeDB.h" #include "NodeListRenderer.h" #include "UIRenderer.h" @@ -313,7 +314,7 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st // === 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); + snprintf(titlestr, sizeof(titlestr), "*%s*", shortName); // === Draw battery/time/mail header (common across screens) === graphics::drawCommonHeader(display, x, y, titlestr); @@ -342,34 +343,162 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st } // === 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); + int bars = 0; - // Always use "Sig" for the label - const char *signalLabel = " Sig"; + // Helper to get SNR limit based on modem preset + auto getSnrLimit = [](meshtastic_Config_LoRaConfig_ModemPreset preset) -> float { + switch (preset) { + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + return -6.0f; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + return -5.5f; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + return -4.5f; + default: + return -6.0f; + } + }; + + // Calculate signal grade using modem preset and SNR only + float snrLimit = getSnrLimit(config.lora.modem_preset); + float snr = node->snr; + + // Determine signal quality label and bars using SNR-only grading + const char *qualityLabel = nullptr; + + if (snr > snrLimit + 10) { + qualityLabel = "Good"; + bars = 4; + } else if (snr > snrLimit + 6) { + qualityLabel = "Good"; + bars = 3; + } else if (snr > snrLimit + 2) { + qualityLabel = "Good"; + bars = 2; + } else if (snr > snrLimit - 4) { + qualityLabel = "Fair"; + bars = 1; + } else { + qualityLabel = "Bad"; + bars = 1; + } + + // Add extra spacing on the left if we have an API connection to account for the common footer icons + const char *leftSideSpacing = + graphics::isAPIConnected(service->api_state) ? (currentResolution == ScreenResolution::High ? " " : " ") : " "; // --- 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); + // Only show signal if we have valid SNR + if (snr > -100 && snr != 0) { + snprintf(signalHopsStr, sizeof(signalHopsStr), "%sSig:%s", leftSideSpacing, qualityLabel); 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")); + snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [#]"); } else { - snprintf(signalHopsStr, sizeof(signalHopsStr), "[%d %s]", node->hops_away, (node->hops_away == 1 ? "Hop" : "Hops")); + snprintf(signalHopsStr, sizeof(signalHopsStr), "[#]"); } } - if (signalHopsStr[0] && line < 5) { - display->drawString(x, getTextPositions(display)[line++], signalHopsStr); + + if (signalHopsStr[0]) { + int yPos = getTextPositions(display)[line++]; + int curX = x; + + // Split combined string into signal text and hop suffix + char sigPart[20] = ""; + const char *hopPart = nullptr; + + char *bracket = strchr(signalHopsStr, '['); + if (bracket) { + size_t n = (size_t)(bracket - signalHopsStr); + if (n >= sizeof(sigPart)) + n = sizeof(sigPart) - 1; + memcpy(sigPart, signalHopsStr, n); + sigPart[n] = '\0'; + + // Trim trailing spaces + while (strlen(sigPart) && sigPart[strlen(sigPart) - 1] == ' ') { + sigPart[strlen(sigPart) - 1] = '\0'; + } + + hopPart = bracket; // "[n Hop(s)]" + } else { + strncpy(sigPart, signalHopsStr, sizeof(sigPart) - 1); + sigPart[sizeof(sigPart) - 1] = '\0'; + } + + // Draw signal quality text + display->drawString(curX, yPos, sigPart); + curX += display->getStringWidth(sigPart) + 4; + + // Draw signal bars (skip on UltraLow, text only) + if (currentResolution != ScreenResolution::UltraLow && haveSignal && bars > 0) { + const int kMaxBars = 4; + if (bars < 1) + bars = 1; + if (bars > kMaxBars) + bars = kMaxBars; + + int barX = curX; + + const bool hi = (currentResolution == ScreenResolution::High); + int barWidth = hi ? 2 : 1; + int barGap = hi ? 2 : 1; + int maxBarHeight = FONT_HEIGHT_SMALL - 7; + if (!hi) + maxBarHeight -= 1; + int barY = yPos + (FONT_HEIGHT_SMALL - maxBarHeight) / 2; + + for (int bi = 0; bi < kMaxBars; bi++) { + int barHeight = maxBarHeight * (bi + 1) / kMaxBars; + if (barHeight < 2) + barHeight = 2; + + int bx = barX + bi * (barWidth + barGap); + int by = barY + maxBarHeight - barHeight; + + if (bi < bars) { + display->fillRect(bx, by, barWidth, barHeight); + } else { + int baseY = barY + maxBarHeight - 1; + display->drawHorizontalLine(bx, baseY, barWidth); + } + } + + curX += (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap) + 2; + } + + // Draw hops AFTER the bars as: [ number + hop icon ] + if (hopPart && node->hops_away > 0) { + + // open bracket + display->drawString(curX, yPos, "["); + curX += display->getStringWidth("[") + 1; + + // hop count + char hopCount[6]; + snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away); + display->drawString(curX, yPos, hopCount); + curX += display->getStringWidth(hopCount) + 2; + + // hop icon + const int iconY = yPos + (FONT_HEIGHT_SMALL - hop_height) / 2; + display->drawXbm(curX, iconY, hop_width, hop_height, hop); + curX += hop_width + 1; + + // closing bracket + display->drawString(curX, yPos, "]"); + } } // === 3. Heard (last seen, skip if node never seen) === @@ -377,8 +506,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st 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"), + // Format as "Heard:Xm ago", "Heard:Xh ago", or "Heard:Xd ago" + snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard:?" : "%sHeard:%d%c ago"), leftSideSpacing, (days ? days : hours ? hours : minutes), @@ -386,16 +515,18 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st : hours ? 'h' : 'm')); } - if (seenStr[0] && line < 5) { + if (seenStr[0]) { display->drawString(x, getTextPositions(display)[line++], seenStr); } #if !defined(M5STACK_UNITC6L) // === 4. Uptime (only show if metric is present) === char uptimeStr[32] = ""; if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) { - getUptimeStr(node->device_metrics.uptime_seconds * 1000, " Up", uptimeStr, sizeof(uptimeStr)); + char upPrefix[12]; // enough for leftSideSpacing + "Up:" + snprintf(upPrefix, sizeof(upPrefix), "%sUp:", leftSideSpacing); + getUptimeStr(node->device_metrics.uptime_seconds * 1000, upPrefix, uptimeStr, sizeof(uptimeStr)); } - if (uptimeStr[0] && line < 5) { + if (uptimeStr[0]) { display->drawString(x, getTextPositions(display)[line++], uptimeStr); } @@ -422,16 +553,16 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st if (miles < 0.1) { int feet = (int)(miles * 5280); if (feet > 0 && feet < 1000) { - snprintf(distStr, sizeof(distStr), " Distance: %dft", feet); + snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet); haveDistance = true; } else if (feet >= 1000) { - snprintf(distStr, sizeof(distStr), " Distance: ΒΌmi"); + snprintf(distStr, sizeof(distStr), "%sDistance:ΒΌmi", leftSideSpacing); haveDistance = true; } } else { int roundedMiles = (int)(miles + 0.5); if (roundedMiles > 0 && roundedMiles < 1000) { - snprintf(distStr, sizeof(distStr), " Distance: %dmi", roundedMiles); + snprintf(distStr, sizeof(distStr), "%sDistance:%dmi", leftSideSpacing, roundedMiles); haveDistance = true; } } @@ -439,26 +570,74 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st if (distanceKm < 1.0) { int meters = (int)(distanceKm * 1000); if (meters > 0 && meters < 1000) { - snprintf(distStr, sizeof(distStr), " Distance: %dm", meters); + snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters); haveDistance = true; } else if (meters >= 1000) { - snprintf(distStr, sizeof(distStr), " Distance: 1km"); + snprintf(distStr, sizeof(distStr), "%sDistance:1km", leftSideSpacing); haveDistance = true; } } else { int km = (int)(distanceKm + 0.5); if (km > 0 && km < 1000) { - snprintf(distStr, sizeof(distStr), " Distance: %dkm", km); + snprintf(distStr, sizeof(distStr), "%sDistance:%dkm", leftSideSpacing, km); haveDistance = true; } } } } - // Only display if we actually have a value! - if (haveDistance && distStr[0] && line < 5) { + if (haveDistance && distStr[0]) { display->drawString(x, getTextPositions(display)[line++], distStr); } + // === 6. Battery after Distance line, otherwise next available line === + char batLine[32] = ""; + bool haveBatLine = false; + + if (node->has_device_metrics) { + bool hasPct = node->device_metrics.has_battery_level; + bool hasVolt = node->device_metrics.has_voltage && node->device_metrics.voltage > 0.001f; + + int pct = 0; + float volt = 0.0f; + + if (hasPct) { + pct = (int)node->device_metrics.battery_level; + } + + if (hasVolt) { + volt = node->device_metrics.voltage; + } + + if (hasPct && pct > 0 && pct <= 100) { + // Normal battery percentage + if (hasVolt) { + snprintf(batLine, sizeof(batLine), "%sBat:%d%% (%.2fV)", leftSideSpacing, pct, volt); + } else { + snprintf(batLine, sizeof(batLine), "%sBat:%d%%", leftSideSpacing, pct); + } + haveBatLine = true; + } else if (hasPct && pct > 100) { + // Plugged in + if (hasVolt) { + snprintf(batLine, sizeof(batLine), "%sPlugged In (%.2fV)", leftSideSpacing, volt); + } else { + snprintf(batLine, sizeof(batLine), "%sPlugged In", leftSideSpacing); + } + haveBatLine = true; + } else if (!hasPct && hasVolt) { + // Voltage only + snprintf(batLine, sizeof(batLine), "%sBat:%.2fV", leftSideSpacing, volt); + haveBatLine = true; + } + } + + const int maxTextLines = (currentResolution == ScreenResolution::High) ? 6 : 5; + + // Only draw battery if it fits within the allowed lines + if (haveBatLine && line <= maxTextLines) { + display->drawString(x, getTextPositions(display)[line++], batLine); + } + // --- Compass Rendering: landscape (wide) screens use the original side-aligned logic --- if (SCREEN_WIDTH > SCREEN_HEIGHT) { bool showCompass = false; @@ -593,7 +772,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta } char uptimeStr[32] = ""; if (currentResolution != ScreenResolution::UltraLow) { - getUptimeStr(millis(), "Up", uptimeStr, sizeof(uptimeStr)); + getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr)); } display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr); @@ -984,7 +1163,6 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU config.display.heading_bold = false; const char *displayLine = ""; // Initialize to empty string by default - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { if (config.position.fixed_position) { @@ -1029,10 +1207,10 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU char uptimeStr[32]; #if defined(USE_EINK) // E-Ink: skip seconds, show only days/hours/mins - getUptimeStr(delta, "Last", uptimeStr, sizeof(uptimeStr), false); + getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), false); #else // Non E-Ink: include seconds where useful - getUptimeStr(delta, "Last", uptimeStr, sizeof(uptimeStr), true); + getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), true); #endif display->drawString(0, getTextPositions(display)[line++], uptimeStr); diff --git a/src/graphics/images.h b/src/graphics/images.h index ef9ffef78..66fcbc79c 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -83,6 +83,12 @@ static const unsigned char mail[] PROGMEM = { 0b11111111, 0b00 // Bottom line }; +// Hop icon (9x10) +#define hop_width 9 +#define hop_height 10 +const uint8_t hop[] PROGMEM = {0x05, 0x00, 0x07, 0x00, 0x05, 0x00, 0x38, 0x00, 0x28, 0x00, + 0x38, 0x00, 0xC0, 0x01, 0x40, 0x01, 0xC0, 0x01, 0x40, 0x00}; + // πŸ“¬ Mail / Message const uint8_t icon_mail[] PROGMEM = { 0b11111111, // β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ top border