Skip to content
Merged
12 changes: 6 additions & 6 deletions src/graphics/TimeFormatters.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,14 @@ void getUptimeStr(uint32_t uptimeMillis, const char *prefix, char *uptimeStr, ui
uint32_t secs = (uptimeMillis % 60000) / 1000;

if (days) {
snprintf(uptimeStr, maxLength, "%s: %ud %uh", prefix, days, hours);
snprintf(uptimeStr, maxLength, "%s%ud %uh", prefix, days, hours);
} else if (hours) {
snprintf(uptimeStr, maxLength, "%s: %uh %um", prefix, hours, mins);
snprintf(uptimeStr, maxLength, "%s%uh %um", prefix, hours, mins);
} else if (!includeSecs) {
snprintf(uptimeStr, maxLength, "%s: %um", prefix, mins);
snprintf(uptimeStr, maxLength, "%s%um", prefix, mins);
} else if (mins) {
snprintf(uptimeStr, maxLength, "%s: %um %us", prefix, mins, secs);
snprintf(uptimeStr, maxLength, "%s%um %us", prefix, mins, secs);
} else {
snprintf(uptimeStr, maxLength, "%s: %us", prefix, secs);
snprintf(uptimeStr, maxLength, "%s%us", prefix, secs);
}
}
}
2 changes: 1 addition & 1 deletion src/graphics/draw/DebugRenderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x

if (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5)) { // Only show uptime if the screen can show it
char uptimeStr[32] = "";
getUptimeStr(millis(), "Up", uptimeStr, sizeof(uptimeStr));
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
textWidth = display->getStringWidth(uptimeStr);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], uptimeStr);
Expand Down
223 changes: 192 additions & 31 deletions src/graphics/draw/UIRenderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -342,60 +342,184 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
}

// === 2. Signal and Hops (combined on one line, if available) ===
// If both are present: "Sig: 97% [2hops]"
// If only one: show only that one
char signalHopsStr[32] = "";
bool haveSignal = false;
int percentSignal = clamp((int)((node->snr + 10) * 5), 0, 100);
int bars = 0;

// Helper to get SNR limit based on modem preset
auto getSnrLimit = [](meshtastic_Config_LoRaConfig_ModemPreset preset) -> float {
switch (preset) {
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST:
return -6.0f;
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
return -5.5f;
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
return -4.5f;
default:
return -6.0f;
}
};
Comment on lines +350 to +367

Copilot AI Feb 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSnrLimit() logic is duplicated here but already exists elsewhere in the codebase (e.g. src/modules/CannedMessageModule.cpp). Consider moving this preset→SNR-limit mapping (and possibly signal grading) into a shared helper so UI and other features stay consistent when presets/thresholds change.

Copilot uses AI. Check for mistakes.

// Always use "Sig" for the label
const char *signalLabel = " Sig";
// Calculate signal grade using modem preset and SNR only
float snrLimit = getSnrLimit(config.lora.modem_preset);
float snr = node->snr;

// Determine signal quality label and bars using SNR-only grading
const char *qualityLabel = nullptr;

if (snr > snrLimit + 10) {
qualityLabel = "Good";
bars = 4;
} else if (snr > snrLimit + 6) {
qualityLabel = "Good";
bars = 3;
} else if (snr > snrLimit + 2) {
qualityLabel = "Good";
bars = 2;
} else if (snr > snrLimit - 4) {
qualityLabel = "Fair";
bars = 1;
} else {
qualityLabel = "Bad";
bars = 1;
}

// --- Build the Signal/Hops line ---
// If SNR looks reasonable, show signal
if ((int)((node->snr + 10) * 5) >= 0 && node->snr > -100) {
snprintf(signalHopsStr, sizeof(signalHopsStr), "%s: %d%%", signalLabel, percentSignal);
// Only show signal if we have valid SNR
if (snr > -100 && snr != 0) {
Comment thread
HarukiToreda marked this conversation as resolved.
Resolved
snprintf(signalHopsStr, sizeof(signalHopsStr), " Sig:%s", qualityLabel);
haveSignal = true;
}
// If hops is valid (>0), show right after signal

if (node->hops_away > 0) {
size_t len = strlen(signalHopsStr);
// Decide between "1 Hop" and "N Hops"
if (haveSignal) {
snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [%d %s]", node->hops_away,
(node->hops_away == 1 ? "Hop" : "Hops"));
snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [#]");
} else {
snprintf(signalHopsStr, sizeof(signalHopsStr), "[%d %s]", node->hops_away, (node->hops_away == 1 ? "Hop" : "Hops"));
snprintf(signalHopsStr, sizeof(signalHopsStr), "[#]");
}
}
if (signalHopsStr[0] && line < 5) {
display->drawString(x, getTextPositions(display)[line++], signalHopsStr);

if (signalHopsStr[0]) {
int yPos = getTextPositions(display)[line++];
int curX = x;

// Split combined string into signal text and hop suffix
char sigPart[20] = "";
const char *hopPart = nullptr;

char *bracket = strchr(signalHopsStr, '[');
if (bracket) {
size_t n = (size_t)(bracket - signalHopsStr);
if (n >= sizeof(sigPart))
n = sizeof(sigPart) - 1;
memcpy(sigPart, signalHopsStr, n);
sigPart[n] = '\0';

// Trim trailing spaces
while (strlen(sigPart) && sigPart[strlen(sigPart) - 1] == ' ') {
sigPart[strlen(sigPart) - 1] = '\0';
}

hopPart = bracket; // "[n Hop(s)]"
} else {
strncpy(sigPart, signalHopsStr, sizeof(sigPart) - 1);
sigPart[sizeof(sigPart) - 1] = '\0';
}

// Draw signal quality text
display->drawString(curX, yPos, sigPart);
curX += display->getStringWidth(sigPart) + 4;

// Draw signal bars (skip on UltraLow, text only)
if (currentResolution != ScreenResolution::UltraLow && haveSignal && bars > 0) {
const int kMaxBars = 4;
if (bars < 1)
bars = 1;
if (bars > kMaxBars)
bars = kMaxBars;

int barX = curX;

const bool hi = (currentResolution == ScreenResolution::High);
int barWidth = hi ? 2 : 1;
int barGap = hi ? 2 : 1;
int maxBarHeight = FONT_HEIGHT_SMALL - 7;
if (!hi)
maxBarHeight -= 1;
int barY = yPos + (FONT_HEIGHT_SMALL - maxBarHeight) / 2;

for (int bi = 0; bi < kMaxBars; bi++) {
int barHeight = maxBarHeight * (bi + 1) / kMaxBars;
if (barHeight < 2)
barHeight = 2;

int bx = barX + bi * (barWidth + barGap);
int by = barY + maxBarHeight - barHeight;

if (bi < bars) {
display->fillRect(bx, by, barWidth, barHeight);
} else {
int baseY = barY + maxBarHeight - 1;
display->drawHorizontalLine(bx, baseY, barWidth);
}
}

curX += (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap) + 2;
}

// Draw hops AFTER the bars as: [ number + hop icon ]
if (hopPart && node->hops_away > 0) {

// open bracket
display->drawString(curX, yPos, "[");
curX += display->getStringWidth("[") + 1;

// hop count
char hopCount[6];
snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away);
display->drawString(curX, yPos, hopCount);
curX += display->getStringWidth(hopCount) + 2;

// hop icon
const int iconY = yPos + (FONT_HEIGHT_SMALL - hop_height) / 2;
display->drawXbm(curX, iconY, hop_width, hop_height, hop);
curX += hop_width + 1;

// closing bracket
display->drawString(curX, yPos, "]");
}
}

// === 3. Heard (last seen, skip if node never seen) ===
char seenStr[20] = "";
uint32_t seconds = sinceLastSeen(node);
if (seconds != 0 && seconds != UINT32_MAX) {
uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
// Format as "Heard: Xm ago", "Heard: Xh ago", or "Heard: Xd ago"
snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard: ?" : " Heard: %d%c ago"),
// Format as "Heard:Xm ago", "Heard:Xh ago", or "Heard:Xd ago"
snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard:?" : " Heard:%d%c ago"),
(days ? days
: hours ? hours
: minutes),
(days ? 'd'
: hours ? 'h'
: 'm'));
}
if (seenStr[0] && line < 5) {
if (seenStr[0]) {
display->drawString(x, getTextPositions(display)[line++], seenStr);
}
#if !defined(M5STACK_UNITC6L)
// === 4. Uptime (only show if metric is present) ===
char uptimeStr[32] = "";
if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) {
getUptimeStr(node->device_metrics.uptime_seconds * 1000, " Up", uptimeStr, sizeof(uptimeStr));
getUptimeStr(node->device_metrics.uptime_seconds * 1000, " Up:", uptimeStr, sizeof(uptimeStr));
Comment thread
HarukiToreda marked this conversation as resolved.
Resolved
}
if (uptimeStr[0] && line < 5) {
if (uptimeStr[0]) {
display->drawString(x, getTextPositions(display)[line++], uptimeStr);
}

Expand All @@ -422,43 +546,81 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
if (miles < 0.1) {
int feet = (int)(miles * 5280);
if (feet > 0 && feet < 1000) {
snprintf(distStr, sizeof(distStr), " Distance: %dft", feet);
snprintf(distStr, sizeof(distStr), " Distance:%dft", feet);
haveDistance = true;
} else if (feet >= 1000) {
snprintf(distStr, sizeof(distStr), " Distance: ¼mi");
snprintf(distStr, sizeof(distStr), " Distance:¼mi");
haveDistance = true;
}
} else {
int roundedMiles = (int)(miles + 0.5);
if (roundedMiles > 0 && roundedMiles < 1000) {
snprintf(distStr, sizeof(distStr), " Distance: %dmi", roundedMiles);
snprintf(distStr, sizeof(distStr), " Distance:%dmi", roundedMiles);
haveDistance = true;
}
}
} else {
if (distanceKm < 1.0) {
int meters = (int)(distanceKm * 1000);
if (meters > 0 && meters < 1000) {
snprintf(distStr, sizeof(distStr), " Distance: %dm", meters);
snprintf(distStr, sizeof(distStr), " Distance:%dm", meters);
haveDistance = true;
} else if (meters >= 1000) {
snprintf(distStr, sizeof(distStr), " Distance: 1km");
snprintf(distStr, sizeof(distStr), " Distance:1km");
haveDistance = true;
}
} else {
int km = (int)(distanceKm + 0.5);
if (km > 0 && km < 1000) {
snprintf(distStr, sizeof(distStr), " Distance: %dkm", km);
snprintf(distStr, sizeof(distStr), " Distance:%dkm", km);
haveDistance = true;
}
}
}
}
// Only display if we actually have a value!
if (haveDistance && distStr[0] && line < 5) {
if (haveDistance && distStr[0]) {
display->drawString(x, getTextPositions(display)[line++], distStr);
}

// === 6.Battery after Distance line, otherwise next available line ===
char batLine[32] = "";
bool haveBatLine = false;

if (node->has_device_metrics) {
// Battery percent
if (node->device_metrics.has_battery_level) {
int pct = (int)node->device_metrics.battery_level;
if (pct < 0)
pct = 0;
if (pct > 100)
pct = 100;
snprintf(batLine, sizeof(batLine), " Bat:%d%%", pct);
haveBatLine = true;
}

// Voltage
if (node->device_metrics.has_voltage) {
float v = node->device_metrics.voltage;
int vi = (int)v;
int vd = (int)(v * 100.0f + 0.5f) % 100;

if (haveBatLine) {
size_t len = strlen(batLine);
snprintf(batLine + len, sizeof(batLine) - len, " %d.%02dV", vi, vd);
} else {
snprintf(batLine, sizeof(batLine), " Bat:%d.%02dV", vi, vd);
haveBatLine = true;
}
}
}

const int maxTextLines = (currentResolution == ScreenResolution::High) ? 6 : 5;

// Only draw battery if it fits within the allowed lines
if (haveBatLine && line <= maxTextLines) {
display->drawString(x, getTextPositions(display)[line++], batLine);
}

// --- Compass Rendering: landscape (wide) screens use the original side-aligned logic ---
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
bool showCompass = false;
Expand Down Expand Up @@ -593,7 +755,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
}
char uptimeStr[32] = "";
if (currentResolution != ScreenResolution::UltraLow) {
getUptimeStr(millis(), "Up", uptimeStr, sizeof(uptimeStr));
getUptimeStr(millis(), "Up:", uptimeStr, sizeof(uptimeStr));
}
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);

Expand Down Expand Up @@ -984,7 +1146,6 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
config.display.heading_bold = false;

const char *displayLine = ""; // Initialize to empty string by default
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());

if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
if (config.position.fixed_position) {
Expand Down Expand Up @@ -1029,7 +1190,7 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
char uptimeStr[32];
#if defined(USE_EINK)
// E-Ink: skip seconds, show only days/hours/mins
getUptimeStr(delta, "Last", uptimeStr, sizeof(uptimeStr), false);
getUptimeStr(delta, "Last:", uptimeStr, sizeof(uptimeStr), false);
#else
// Non E-Ink: include seconds where useful
getUptimeStr(delta, "Last", uptimeStr, sizeof(uptimeStr), true);
Expand Down
6 changes: 6 additions & 0 deletions src/graphics/images.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ static const unsigned char mail[] PROGMEM = {
0b11111111, 0b00 // Bottom line
};

// Hop icon (9x10)
#define hop_width 9
#define hop_height 10
const uint8_t hop[] PROGMEM = {0x05, 0x00, 0x07, 0x00, 0x05, 0x00, 0x38, 0x00, 0x28, 0x00,
0x38, 0x00, 0xC0, 0x01, 0x40, 0x01, 0xC0, 0x01, 0x40, 0x00};

// 📬 Mail / Message
const uint8_t icon_mail[] PROGMEM = {
0b11111111, // ████████ top border
Expand Down