Telemetry Screen Module

This commit is contained in:
HarukiToreda 2025-05-05 21:27:20 -04:00
parent 66a06230c4
commit 849e06497a
5 changed files with 369 additions and 324 deletions

View File

@ -52,6 +52,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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 <Throttle.h>
@ -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)

View File

@ -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 <OLEDDisplay.h>
#include <graphics/images.h>
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

View File

@ -0,0 +1,37 @@
#pragma once
#include <OLEDDisplay.h>
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

View File

@ -17,6 +17,8 @@
#include "target_specific.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayUi.h>
#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(<Adafruit_AHTX0.h>)
#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;
}
sensorData[sensorCount++] =
"Temp/Hum: " + last_temp + " / " + String(lastMeasurement.variant.environment_metrics.relative_humidity, 0) + "%";
// === 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<String> 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
}
if (lastMeasurement.variant.environment_metrics.barometric_pressure != 0) {
sensorData[sensorCount++] =
"Press: " + String(lastMeasurement.variant.environment_metrics.barometric_pressure, 0) + "hPA";
// === 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]);
}
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;
}
}

View File

@ -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 <Throttle.h>
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");
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);
}
}