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);
}
}