From 849e06497a6589eac9f37139a036a34da031b1b7 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Mon, 5 May 2025 21:27:20 -0400 Subject: [PATCH] Telemetry Screen Module --- src/graphics/Screen.cpp | 215 +---------------- src/graphics/SharedUIDisplay.cpp | 164 +++++++++++++ src/graphics/SharedUIDisplay.h | 37 +++ .../Telemetry/EnvironmentTelemetry.cpp | 216 ++++++++++-------- src/modules/Telemetry/PowerTelemetry.cpp | 61 +++-- 5 files changed, 369 insertions(+), 324 deletions(-) create mode 100644 src/graphics/SharedUIDisplay.cpp create mode 100644 src/graphics/SharedUIDisplay.h diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index f2eb0956f..ad121610a 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -52,6 +52,7 @@ along with this program. If not, see . #include "modules/WaypointModule.h" #include "sleep.h" #include "target_specific.h" +#include "graphics/SharedUIDisplay.h" #if HAS_WIFI && !defined(ARCH_PORTDUINO) #include "mesh/wifi/WiFiAPClient.h" @@ -115,23 +116,6 @@ GeoCoord geoCoord; 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() - -// Pre-defined lines; this is intended to be used AFTER the common header -#define compactFirstLine ((FONT_HEIGHT_SMALL - 1) * 1) -#define compactSecondLine ((FONT_HEIGHT_SMALL - 1) * 2) - 2 -#define compactThirdLine ((FONT_HEIGHT_SMALL - 1) * 3) - 4 -#define compactFourthLine ((FONT_HEIGHT_SMALL - 1) * 4) - 6 -#define compactFifthLine ((FONT_HEIGHT_SMALL - 1) * 5) - 8 - -#define standardFirstLine (FONT_HEIGHT_SMALL + 1) * 1 -#define standardSecondLine (FONT_HEIGHT_SMALL + 1) * 2 -#define standardThirdLine (FONT_HEIGHT_SMALL + 1) * 3 -#define standardFourthLine (FONT_HEIGHT_SMALL + 1) * 4 - #include "graphics/ScreenFonts.h" #include @@ -995,188 +979,6 @@ bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int return validCached; } // ********************************* -// *Rounding Header when inverted * -// ********************************* -void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, int16_t h, int16_t r) -{ - display->fillRect(x + r, y, w - 2 * r, h); - display->fillRect(x, y + r, r, h - 2 * r); - display->fillRect(x + w - r, y + r, r, h - 2 * r); - display->fillCircle(x + r + 1, y + r, r); - display->fillCircle(x + w - r - 1, y + r, r); - display->fillCircle(x + r + 1, y + h - r - 1, r); - display->fillCircle(x + w - r - 1, y + h - r - 1, r); -} -bool isBoltVisible = true; -uint32_t lastBlink = 0; -const uint32_t blinkInterval = 500; -static uint32_t lastMailBlink = 0; -static bool isMailIconVisible = true; -constexpr uint32_t mailBlinkInterval = 500; - -// *********************** -// * Common Header * -// *********************** -void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y) -{ - constexpr int HEADER_OFFSET_Y = 1; - y += HEADER_OFFSET_Y; - - const bool isInverted = (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED); - const bool isBold = config.display.heading_bold; - const int xOffset = 4; - const int highlightHeight = FONT_HEIGHT_SMALL - 1; - - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - - // === Background highlight === - if (isInverted) { - drawRoundedHighlight(display, x, y, SCREEN_WIDTH, highlightHeight, 2); - display->setColor(BLACK); - } - - // === Battery Vertical and Horizontal === - int chargePercent = powerStatus->getBatteryChargePercent(); - bool isCharging = powerStatus->getIsCharging() == OptionalBool::OptTrue; - uint32_t now = millis(); - if (isCharging && now - lastBlink > blinkInterval) { - isBoltVisible = !isBoltVisible; - lastBlink = now; - } - - // Hybrid condition: wide screen AND landscape layout - bool useHorizontalBattery = (SCREEN_WIDTH > 128 && SCREEN_WIDTH > SCREEN_HEIGHT); - - if (useHorizontalBattery) { - // === Horizontal battery === - int batteryX = 2; - int batteryY = HEADER_OFFSET_Y + 2; - - display->drawXbm(batteryX, batteryY, 29, 15, batteryBitmap_h); - - if (isCharging && isBoltVisible) { - display->drawXbm(batteryX + 9, batteryY + 1, 9, 13, lightning_bolt_h); - } else if (isCharging && !isBoltVisible) { - display->drawXbm(batteryX + 8, batteryY, 12, 15, batteryBitmap_sidegaps_h); - } else { - display->drawXbm(batteryX + 8, batteryY, 12, 15, batteryBitmap_sidegaps_h); - int fillWidth = 24 * chargePercent / 100; - int fillX = batteryX + fillWidth; - display->fillRect(batteryX + 1, batteryY + 1, fillX, 13); - } - } else { - // === Vertical battery === - int batteryX = 1; - int batteryY = HEADER_OFFSET_Y + 1; -#ifdef USE_EINK - batteryY = batteryY + 2; -#endif - - display->drawXbm(batteryX, batteryY, 7, 11, batteryBitmap_v); - - if (isCharging && isBoltVisible) { - display->drawXbm(batteryX + 1, batteryY + 3, 5, 5, lightning_bolt_v); - } else if (isCharging && !isBoltVisible) { - display->drawXbm(batteryX - 1, batteryY + 4, 8, 3, batteryBitmap_sidegaps_v); - } else { - display->drawXbm(batteryX - 1, batteryY + 4, 8, 3, batteryBitmap_sidegaps_v); - int fillHeight = 8 * chargePercent / 100; - int fillY = batteryY - fillHeight; - display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight); - } - } - - // === Text baseline === - const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2; - - // === Battery % Text === - char chargeStr[4]; - snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent); - - int chargeNumWidth = display->getStringWidth(chargeStr); - const int batteryOffset = useHorizontalBattery ? 28 : 6; -#ifdef USE_EINK - const int percentX = x + xOffset + batteryOffset - 2; -#else - const int percentX = x + xOffset + batteryOffset; -#endif - - display->drawString(percentX, textY, chargeStr); - display->drawString(percentX + chargeNumWidth - 1, textY, "%"); - - if (isBold) { - display->drawString(percentX + 1, textY, chargeStr); - display->drawString(percentX + chargeNumWidth, textY, "%"); - } - // === Time string (right-aligned) === - uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); - if (rtc_sec > 0) { - long hms = (rtc_sec % SEC_PER_DAY + SEC_PER_DAY) % SEC_PER_DAY; - int hour = hms / SEC_PER_HOUR; - int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; - - char timeStr[10]; - - snprintf(timeStr, sizeof(timeStr), "%d:%02d", hour, minute); - if (config.display.use_12h_clock) { - bool isPM = hour >= 12; - hour = hour % 12; - if (hour == 0) - hour = 12; - snprintf(timeStr, sizeof(timeStr), "%d:%02d%s", hour, minute, isPM ? "p" : "a"); - } - - int timeStrWidth = display->getStringWidth(timeStr); - int timeX = SCREEN_WIDTH - xOffset - timeStrWidth + 4; // time to the right by 4 - - // Mail icon next to time (drawn as 'M' in a tight square) - if (hasUnreadMessage) { - if (now - lastMailBlink > mailBlinkInterval) { - isMailIconVisible = !isMailIconVisible; - lastMailBlink = now; - } - - if (isMailIconVisible) { - const bool isWide = useHorizontalBattery; - - if (isWide) { - // Dimensions for the wide mail icon - const int iconW = 16; - const int iconH = 12; - - const int iconX = timeX - iconW - 3; - const int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1; - - // Draw envelope rectangle - display->drawRect(iconX, iconY, iconW, iconH); - - // Define envelope corners and center - const int leftX = iconX + 1; - const int rightX = iconX + iconW - 2; - const int topY = iconY + 1; - const int bottomY = iconY + iconH - 2; - const int centerX = iconX + iconW / 2; - const int peakY = bottomY - 1; - - // Draw "M" diagonals - display->drawLine(leftX, topY, centerX, peakY); - display->drawLine(rightX, topY, centerX, peakY); - } else { - // Small icon for non-wide screens - const int iconX = timeX - mail_width; - const int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; - display->drawXbm(iconX, iconY, mail_width, mail_height, mail); - } - } - } - display->drawString(timeX, textY, timeStr); - if (isBold) - display->drawString(timeX - 1, textY, timeStr); - } - - display->setColor(WHITE); -} // **************************** // * Text Message Screen * @@ -1767,7 +1569,7 @@ static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_ display->setFont(FONT_SMALL); // === Header === - drawCommonHeader(display, x, y); + graphics::drawCommonHeader(display, x, y); // === Reset color in case inverted mode left it BLACK === display->setColor(WHITE); @@ -2081,7 +1883,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t display->clear(); // === Draw the battery/time header === - drawCommonHeader(display, x, y); + graphics::drawCommonHeader(display, x, y); // === Manually draw the centered title within the header === const int highlightHeight = COMMON_HEADER_HEIGHT; @@ -2502,7 +2304,7 @@ static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, i display->setFont(FONT_SMALL); // === Header === - drawCommonHeader(display, x, y); + graphics::drawCommonHeader(display, x, y); // === Content below header === @@ -2599,7 +2401,7 @@ static void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int display->setFont(FONT_SMALL); // === Header === - drawCommonHeader(display, x, y); + graphics::drawCommonHeader(display, x, y); // === Draw title (aligned with header baseline) === const int highlightHeight = FONT_HEIGHT_SMALL - 1; @@ -2720,7 +2522,7 @@ static void drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiStat display->setFont(FONT_SMALL); // === Header === - drawCommonHeader(display, x, y); + graphics::drawCommonHeader(display, x, y); // === Draw title === const int highlightHeight = FONT_HEIGHT_SMALL - 1; @@ -2850,7 +2652,7 @@ static void drawMemoryScreen(OLEDDisplay *display, OLEDDisplayUiState *state, in display->setTextAlignment(TEXT_ALIGN_LEFT); // === Header === - drawCommonHeader(display, x, y); + graphics::drawCommonHeader(display, x, y); // === Draw title === const int highlightHeight = FONT_HEIGHT_SMALL - 1; @@ -3152,8 +2954,7 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) } static int8_t lastFrameIndex = -1; static uint32_t lastFrameChangeTime = 0; -// constexpr uint32_t ICON_DISPLAY_DURATION_MS = 1250; -constexpr uint32_t ICON_DISPLAY_DURATION_MS = 10250; +constexpr uint32_t ICON_DISPLAY_DURATION_MS = 1250; // Bottom navigation icons void drawCustomFrameIcons(OLEDDisplay *display, OLEDDisplayUiState *state) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp new file mode 100644 index 000000000..b6a0a4d46 --- /dev/null +++ b/src/graphics/SharedUIDisplay.cpp @@ -0,0 +1,164 @@ +#include "graphics/SharedUIDisplay.h" +#include "graphics/ScreenFonts.h" +#include "main.h" +#include "power.h" +#include "meshtastic/config.pb.h" +#include "RTC.h" +#include +#include + +namespace graphics { + +// === Shared External State === +bool hasUnreadMessage = false; + +// === Internal State === +bool isBoltVisibleShared = true; +uint32_t lastBlinkShared = 0; +bool isMailIconVisible = true; +uint32_t lastMailBlink = 0; + +// ********************************* +// * Rounded Header when inverted * +// ********************************* +void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, int16_t h, int16_t r) +{ + display->fillRect(x + r, y, w - 2 * r, h); + display->fillRect(x, y + r, r, h - 2 * r); + display->fillRect(x + w - r, y + r, r, h - 2 * r); + display->fillCircle(x + r + 1, y + r, r); + display->fillCircle(x + w - r - 1, y + r, r); + display->fillCircle(x + r + 1, y + h - r - 1, r); + display->fillCircle(x + w - r - 1, y + h - r - 1, r); +} + +// ************************* +// * Common Header Drawing * +// ************************* +void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y) +{ + constexpr int HEADER_OFFSET_Y = 1; + y += HEADER_OFFSET_Y; + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const int xOffset = 4; + const int highlightHeight = FONT_HEIGHT_SMALL - 1; + const bool isInverted = (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED); + const bool isBold = config.display.heading_bold; + + const int screenW = display->getWidth(); + const int screenH = display->getHeight(); + + if (isInverted) { + drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2); + display->setColor(BLACK); + } + + int chargePercent = powerStatus->getBatteryChargePercent(); + bool isCharging = powerStatus->getIsCharging() == meshtastic::OptionalBool::OptTrue; + uint32_t now = millis(); + if (isCharging && now - lastBlinkShared > 500) { + isBoltVisibleShared = !isBoltVisibleShared; + lastBlinkShared = now; + } + + bool useHorizontalBattery = (screenW > 128 && screenW > screenH); + const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2; + + // === Battery Icons === + if (useHorizontalBattery) { + int batteryX = 2; + int batteryY = HEADER_OFFSET_Y + 2; + display->drawXbm(batteryX, batteryY, 29, 15, batteryBitmap_h); + if (isCharging && isBoltVisibleShared) { + display->drawXbm(batteryX + 9, batteryY + 1, 9, 13, lightning_bolt_h); + } else { + display->drawXbm(batteryX + 8, batteryY, 12, 15, batteryBitmap_sidegaps_h); + int fillWidth = 24 * chargePercent / 100; + display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 13); + } + } else { + int batteryX = 1; + int batteryY = HEADER_OFFSET_Y + 1; + #ifdef USE_EINK + batteryY += 2; + #endif + display->drawXbm(batteryX, batteryY, 7, 11, batteryBitmap_v); + if (isCharging && isBoltVisibleShared) { + display->drawXbm(batteryX + 1, batteryY + 3, 5, 5, lightning_bolt_v); + } else { + display->drawXbm(batteryX - 1, batteryY + 4, 8, 3, batteryBitmap_sidegaps_v); + int fillHeight = 8 * chargePercent / 100; + int fillY = batteryY - fillHeight; + display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight); + } + } + + // === Battery % Text === + char chargeStr[4]; + snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent); + int chargeNumWidth = display->getStringWidth(chargeStr); + const int batteryOffset = useHorizontalBattery ? 28 : 6; +#ifdef USE_EINK + const int percentX = x + xOffset + batteryOffset - 2; +#else + const int percentX = x + xOffset + batteryOffset; +#endif + display->drawString(percentX, textY, chargeStr); + display->drawString(percentX + chargeNumWidth - 1, textY, "%"); + if (isBold) { + display->drawString(percentX + 1, textY, chargeStr); + display->drawString(percentX + chargeNumWidth, textY, "%"); + } + + // === Time and Mail Icon === + uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); + if (rtc_sec > 0) { + long hms = (rtc_sec % SEC_PER_DAY + SEC_PER_DAY) % SEC_PER_DAY; + int hour = hms / SEC_PER_HOUR; + int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; + + char timeStr[10]; + snprintf(timeStr, sizeof(timeStr), "%d:%02d", hour, minute); + if (config.display.use_12h_clock) { + bool isPM = hour >= 12; + hour %= 12; + if (hour == 0) hour = 12; + snprintf(timeStr, sizeof(timeStr), "%d:%02d%s", hour, minute, isPM ? "p" : "a"); + } + + int timeStrWidth = display->getStringWidth(timeStr); + int timeX = screenW - xOffset - timeStrWidth + 4; + + if (hasUnreadMessage) { + if (now - lastMailBlink > 500) { + isMailIconVisible = !isMailIconVisible; + lastMailBlink = now; + } + + if (isMailIconVisible) { + if (useHorizontalBattery) { + int iconW = 16, iconH = 12; + int iconX = timeX - iconW - 3; + int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1; + display->drawRect(iconX, iconY, iconW, iconH); + display->drawLine(iconX + 1, iconY + 1, iconX + iconW / 2, iconY + iconH - 2); + display->drawLine(iconX + iconW - 2, iconY + 1, iconX + iconW / 2, iconY + iconH - 2); + } else { + int iconX = timeX - mail_width; + int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; + display->drawXbm(iconX, iconY, mail_width, mail_height, mail); + } + } + } + + display->drawString(timeX, textY, timeStr); + if (isBold) display->drawString(timeX - 1, textY, timeStr); + } + + display->setColor(WHITE); +} + +} // namespace graphics diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h new file mode 100644 index 000000000..99508efde --- /dev/null +++ b/src/graphics/SharedUIDisplay.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +namespace graphics { + +// ======================= +// Shared UI Helpers +// ======================= + +// Compact line layout +#define compactFirstLine ((FONT_HEIGHT_SMALL - 1) * 1) +#define compactSecondLine ((FONT_HEIGHT_SMALL - 1) * 2) - 2 +#define compactThirdLine ((FONT_HEIGHT_SMALL - 1) * 3) - 4 +#define compactFourthLine ((FONT_HEIGHT_SMALL - 1) * 4) - 6 +#define compactFifthLine ((FONT_HEIGHT_SMALL - 1) * 5) - 8 + +// Standard line layout +#define standardFirstLine (FONT_HEIGHT_SMALL + 1) * 1 +#define standardSecondLine (FONT_HEIGHT_SMALL + 1) * 2 +#define standardThirdLine (FONT_HEIGHT_SMALL + 1) * 3 +#define standardFourthLine (FONT_HEIGHT_SMALL + 1) * 4 + +// Quick screen access +#define SCREEN_WIDTH display->getWidth() +#define SCREEN_HEIGHT display->getHeight() + +// Shared state (declare inside namespace) +extern bool hasUnreadMessage; + +// Rounded highlight (used for inverted headers) +void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, int16_t h, int16_t r); + +// Shared battery/time/mail header +void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y); + +} // namespace graphics diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 32c660bbf..fd2d3b219 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -17,6 +17,8 @@ #include "target_specific.h" #include #include +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR_EXTERNAL // Sensors @@ -25,6 +27,9 @@ #include "Sensor/RCWL9620Sensor.h" #include "Sensor/nullSensor.h" +namespace graphics { + extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y); +} #if __has_include() #include "Sensor/AHT10.h" AHT10Sensor aht10Sensor; @@ -312,119 +317,130 @@ bool EnvironmentTelemetryModule::wantUIFrame() void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { - display->setTextAlignment(TEXT_ALIGN_LEFT); + // === Setup display === + display->clear(); display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); - if (lastMeasurementPacket == nullptr) { - // If there's no valid packet, display "Environment" - display->drawString(x, y, "Environment"); - display->drawString(x, y += _fontHeight(FONT_SMALL), "No measurement"); + // Draw shared header bar (battery, time, etc.) + graphics::drawCommonHeader(display, x, y); + + // === Draw Title (Centered under header) === + const int highlightHeight = FONT_HEIGHT_SMALL - 1; + const int titleY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2; + const char *titleStr = "Environment"; + const int centerX = x + SCREEN_WIDTH / 2; + + // Use black text on white background if in inverted mode + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) + display->setColor(BLACK); + + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(centerX, titleY, titleStr); // Centered title + if (config.display.heading_bold) + display->drawString(centerX + 1, titleY, titleStr); // Bold effect via 1px offset + + // Restore text color & alignment + display->setColor(WHITE); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + // === Row spacing setup === + const int rowHeight = FONT_HEIGHT_SMALL - 4; + int currentY = compactFirstLine; + + // === Show "No Telemetry" if no data available === + if (!lastMeasurementPacket) { + display->drawString(x, currentY, "No Telemetry"); return; } - // Decode the last measurement packet - meshtastic_Telemetry lastMeasurement; - uint32_t agoSecs = service->GetTimeSinceMeshPacket(lastMeasurementPacket); - const char *lastSender = getSenderShortName(*lastMeasurementPacket); - + // Decode the telemetry message from the latest received packet const meshtastic_Data &p = lastMeasurementPacket->decoded; - if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &lastMeasurement)) { - display->drawString(x, y, "Measurement Error"); - LOG_ERROR("Unable to decode last packet"); + meshtastic_Telemetry telemetry; + if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &telemetry)) { + display->drawString(x, currentY, "No Telemetry"); return; } - // Display "Env. From: ..." on its own - display->drawString(x, y, "Env. From: " + String(lastSender) + " (" + String(agoSecs) + "s)"); + const auto &m = telemetry.variant.environment_metrics; - // Prepare sensor data strings - String sensorData[10]; - int sensorCount = 0; + // Check if any telemetry field has valid data + bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || + m.iaq != 0 || m.voltage != 0 || m.current != 0 || m.lux != 0 || + m.white_lux != 0 || m.weight != 0 || m.distance != 0 || m.radiation != 0; - if (lastMeasurement.variant.environment_metrics.has_temperature || - lastMeasurement.variant.environment_metrics.has_relative_humidity) { - String last_temp = String(lastMeasurement.variant.environment_metrics.temperature, 0) + "°C"; - if (moduleConfig.telemetry.environment_display_fahrenheit) { - last_temp = - String(UnitConversions::CelsiusToFahrenheit(lastMeasurement.variant.environment_metrics.temperature), 0) + "°F"; + if (!hasAny) { + display->drawString(x, currentY, "No Telemetry"); + return; + } + + // === First line: Show sender name + time since received (left), and first metric (right) === + const char *sender = getSenderShortName(*lastMeasurementPacket); + uint32_t agoSecs = service->GetTimeSinceMeshPacket(lastMeasurementPacket); + String agoStr = (agoSecs > 864000) ? "?" : + (agoSecs > 3600) ? String(agoSecs / 3600) + "h" : + (agoSecs > 60) ? String(agoSecs / 60) + "m" : + String(agoSecs) + "s"; + + String leftStr = String(sender) + " (" + agoStr + ")"; + display->drawString(x, currentY, leftStr); // Left side: who and when + + // === Collect sensor readings as label strings (no icons) === + std::vector entries; + + if (m.has_temperature) { + String tempStr = moduleConfig.telemetry.environment_display_fahrenheit + ? "Tmp: " + String(UnitConversions::CelsiusToFahrenheit(m.temperature), 1) + "°F" + : "Tmp: " + String(m.temperature, 1) + "°C"; + entries.push_back(tempStr); + } + if (m.has_relative_humidity) + entries.push_back("Hum: " + String(m.relative_humidity, 0) + "%"); + if (m.barometric_pressure != 0) + entries.push_back("Prss: " + String(m.barometric_pressure, 0) + " hPa"); + if (m.iaq != 0) { + String aqi = "IAQ: " + String(m.iaq); + if (m.iaq < 50) aqi += " (Good)"; + else if (m.iaq < 100) aqi += " (Moderate)"; + else aqi += " (!)"; + entries.push_back(aqi); + } + if (m.voltage != 0 || m.current != 0) + entries.push_back(String(m.voltage, 1) + "V / " + String(m.current, 0) + "mA"); + if (m.lux != 0) + entries.push_back("Light: " + String(m.lux, 0) + "lx"); + if (m.white_lux != 0) + entries.push_back("White: " + String(m.white_lux, 0) + "lx"); + if (m.weight != 0) + entries.push_back("Weight: " + String(m.weight, 0) + "kg"); + if (m.distance != 0) + entries.push_back("Level: " + String(m.distance, 0) + "mm"); + if (m.radiation != 0) + entries.push_back("Rad: " + String(m.radiation, 2) + " µR/h"); + + // === Show first available metric on top-right of first line === + if (!entries.empty()) { + String valueStr = entries.front(); + int rightX = SCREEN_WIDTH - display->getStringWidth(valueStr); + display->drawString(rightX, currentY, valueStr); + entries.erase(entries.begin()); // Remove from queue + } + + // === Advance to next line for remaining telemetry entries === + currentY += rowHeight; + + // === Draw remaining entries in 2-column format (left and right) === + for (size_t i = 0; i < entries.size(); i += 2) { + // Left column + display->drawString(x, currentY, entries[i]); + + // Right column if it exists + if (i + 1 < entries.size()) { + int rightX = SCREEN_WIDTH / 2; + display->drawString(rightX, currentY, entries[i + 1]); } - sensorData[sensorCount++] = - "Temp/Hum: " + last_temp + " / " + String(lastMeasurement.variant.environment_metrics.relative_humidity, 0) + "%"; - } - - if (lastMeasurement.variant.environment_metrics.barometric_pressure != 0) { - sensorData[sensorCount++] = - "Press: " + String(lastMeasurement.variant.environment_metrics.barometric_pressure, 0) + "hPA"; - } - - if (lastMeasurement.variant.environment_metrics.voltage != 0) { - sensorData[sensorCount++] = "Volt/Cur: " + String(lastMeasurement.variant.environment_metrics.voltage, 0) + "V / " + - String(lastMeasurement.variant.environment_metrics.current, 0) + "mA"; - } - - if (lastMeasurement.variant.environment_metrics.iaq != 0) { - sensorData[sensorCount++] = "IAQ: " + String(lastMeasurement.variant.environment_metrics.iaq); - } - - if (lastMeasurement.variant.environment_metrics.distance != 0) { - sensorData[sensorCount++] = "Water Level: " + String(lastMeasurement.variant.environment_metrics.distance, 0) + "mm"; - } - - if (lastMeasurement.variant.environment_metrics.weight != 0) { - sensorData[sensorCount++] = "Weight: " + String(lastMeasurement.variant.environment_metrics.weight, 0) + "kg"; - } - - if (lastMeasurement.variant.environment_metrics.radiation != 0) { - sensorData[sensorCount++] = "Rad: " + String(lastMeasurement.variant.environment_metrics.radiation, 2) + "µR/h"; - } - - if (lastMeasurement.variant.environment_metrics.lux != 0) { - sensorData[sensorCount++] = "Illuminance: " + String(lastMeasurement.variant.environment_metrics.lux, 2) + "lx"; - } - - if (lastMeasurement.variant.environment_metrics.white_lux != 0) { - sensorData[sensorCount++] = "W_Lux: " + String(lastMeasurement.variant.environment_metrics.white_lux, 2) + "lx"; - } - - static int scrollOffset = 0; - static bool scrollingDown = true; - static uint32_t lastScrollTime = millis(); - - // Determine how many lines we can fit on display - // Calculated once only: display dimensions don't change during runtime. - static int maxLines = 0; - if (!maxLines) { - const int16_t paddingTop = _fontHeight(FONT_SMALL); // Heading text - const int16_t paddingBottom = 8; // Indicator dots - maxLines = (display->getHeight() - paddingTop - paddingBottom) / _fontHeight(FONT_SMALL); - assert(maxLines > 0); - } - - // Draw as many lines of data as we can fit - int linesToShow = min(maxLines, sensorCount); - for (int i = 0; i < linesToShow; i++) { - int index = (scrollOffset + i) % sensorCount; - display->drawString(x, y += _fontHeight(FONT_SMALL), sensorData[index]); - } - - // Only scroll if there are more than 3 sensor data lines - if (sensorCount > 3) { - // Update scroll offset every 5 seconds - if (millis() - lastScrollTime > 5000) { - if (scrollingDown) { - scrollOffset++; - if (scrollOffset + linesToShow >= sensorCount) { - scrollingDown = false; - } - } else { - scrollOffset--; - if (scrollOffset <= 0) { - scrollingDown = true; - } - } - lastScrollTime = millis(); - } + currentY += rowHeight; } } diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 54ec90dae..5190a5947 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -14,6 +14,7 @@ #include "power.h" #include "sleep.h" #include "target_specific.h" +#include "graphics/SharedUIDisplay.h" #define FAILED_STATE_SENSOR_READ_MULTIPLIER 10 #define DISPLAY_RECEIVEID_MEASUREMENTS_ON_SCREEN true @@ -21,6 +22,10 @@ #include "graphics/ScreenFonts.h" #include +namespace graphics { + extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y); +} + int32_t PowerTelemetryModule::runOnce() { if (sleepOnNextExecution == true) { @@ -103,13 +108,33 @@ bool PowerTelemetryModule::wantUIFrame() void PowerTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + display->clear(); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); + graphics::drawCommonHeader(display, x, y); // Shared UI header + + // === Draw title (aligned with header baseline) === + const int highlightHeight = FONT_HEIGHT_SMALL - 1; + const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2; + const char *titleStr = (SCREEN_WIDTH > 128) ? "Power Telem." : "Power"; + const int centerX = x + SCREEN_WIDTH / 2; + + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + display->setColor(BLACK); + } + + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(centerX, textY, titleStr); + if (config.display.heading_bold) { + display->drawString(centerX + 1, textY, titleStr); + } + display->setColor(WHITE); + display->setTextAlignment(TEXT_ALIGN_LEFT); + if (lastMeasurementPacket == nullptr) { - // In case of no valid packet, display "Power Telemetry", "No measurement" - display->drawString(x, y, "Power Telemetry"); - display->drawString(x, y += _fontHeight(FONT_SMALL), "No measurement"); + // In case of no valid packet, display "Power Telemetry", "No measurement" + display->drawString(x, compactFirstLine, "No measurement"); return; } @@ -120,29 +145,31 @@ void PowerTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *s const meshtastic_Data &p = lastMeasurementPacket->decoded; if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &lastMeasurement)) { - display->drawString(x, y, "Measurement Error"); + display->drawString(x, compactFirstLine, "Measurement Error"); LOG_ERROR("Unable to decode last packet"); return; } // Display "Pow. From: ..." - display->drawString(x, y, "Pow. From: " + String(lastSender) + "(" + String(agoSecs) + "s)"); + display->drawString(x, compactFirstLine, "Pow. From: " + String(lastSender) + " (" + String(agoSecs) + "s)"); // Display current and voltage based on ...power_metrics.has_[channel/voltage/current]... flags - if (lastMeasurement.variant.power_metrics.has_ch1_voltage || lastMeasurement.variant.power_metrics.has_ch1_current) { - display->drawString(x, y += _fontHeight(FONT_SMALL), - "Ch1: " + String(lastMeasurement.variant.power_metrics.ch1_voltage, 2) + "V " + - String(lastMeasurement.variant.power_metrics.ch1_current, 0) + "mA"); + const auto &m = lastMeasurement.variant.power_metrics; + int lineY = compactSecondLine; + + auto drawLine = [&](const char *label, float voltage, float current) { + display->drawString(x, lineY, String(label) + ": " + String(voltage, 2) + "V " + String(current, 0) + "mA"); + lineY += _fontHeight(FONT_SMALL); + }; + + if (m.has_ch1_voltage || m.has_ch1_current) { + drawLine("Ch1", m.ch1_voltage, m.ch1_current); } - if (lastMeasurement.variant.power_metrics.has_ch2_voltage || lastMeasurement.variant.power_metrics.has_ch2_current) { - display->drawString(x, y += _fontHeight(FONT_SMALL), - "Ch2: " + String(lastMeasurement.variant.power_metrics.ch2_voltage, 2) + "V " + - String(lastMeasurement.variant.power_metrics.ch2_current, 0) + "mA"); + if (m.has_ch2_voltage || m.has_ch2_current) { + drawLine("Ch2", m.ch2_voltage, m.ch2_current); } - if (lastMeasurement.variant.power_metrics.has_ch3_voltage || lastMeasurement.variant.power_metrics.has_ch3_current) { - display->drawString(x, y += _fontHeight(FONT_SMALL), - "Ch3: " + String(lastMeasurement.variant.power_metrics.ch3_voltage, 2) + "V " + - String(lastMeasurement.variant.power_metrics.ch3_current, 0) + "mA"); + if (m.has_ch3_voltage || m.has_ch3_current) { + drawLine("Ch3", m.ch3_voltage, m.ch3_current); } }