mirror of
https://github.com/meshtastic/firmware.git
synced 2025-08-17 02:35:08 +00:00
812 lines
29 KiB
C++
812 lines
29 KiB
C++
#include "NodeListRenderer.h"
|
|
#include "CompassRenderer.h"
|
|
#include "NodeDB.h"
|
|
#include "UIRenderer.h"
|
|
#include "configuration.h"
|
|
#include "gps/GeoCoord.h"
|
|
#include "gps/RTC.h" // for getTime() function
|
|
#include "graphics/ScreenFonts.h"
|
|
#include "graphics/SharedUIDisplay.h"
|
|
#include "graphics/images.h"
|
|
#include <algorithm>
|
|
|
|
// Forward declarations for functions defined in Screen.cpp
|
|
namespace graphics
|
|
{
|
|
extern bool haveGlyphs(const char *str);
|
|
} // namespace graphics
|
|
|
|
// Global screen instance
|
|
extern graphics::Screen *screen;
|
|
|
|
namespace graphics
|
|
{
|
|
namespace NodeListRenderer
|
|
{
|
|
|
|
// Function moved from Screen.cpp to NodeListRenderer.cpp since it's primarily used here
|
|
void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display)
|
|
{
|
|
for (int row = 0; row < height; row++) {
|
|
uint8_t rowMask = (1 << row);
|
|
for (int col = 0; col < width; col++) {
|
|
uint8_t colData = pgm_read_byte(&bitmapXBM[col]);
|
|
if (colData & rowMask) {
|
|
// Note: rows become X, columns become Y after transpose
|
|
display->fillRect(x + row * 2, y + col * 2, 2, 2);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Static variables for dynamic cycling
|
|
static NodeListMode currentMode = MODE_LAST_HEARD;
|
|
static int scrollIndex = 0;
|
|
|
|
// =============================
|
|
// Utility Functions
|
|
// =============================
|
|
|
|
String getSafeNodeName(meshtastic_NodeInfoLite *node)
|
|
{
|
|
String nodeName = "?";
|
|
if (node->has_user && strlen(node->user.short_name) > 0) {
|
|
bool valid = true;
|
|
const char *name = node->user.short_name;
|
|
for (size_t i = 0; i < strlen(name); i++) {
|
|
uint8_t c = (uint8_t)name[i];
|
|
if (c < 32 || c > 126) {
|
|
valid = false;
|
|
break;
|
|
}
|
|
}
|
|
if (valid) {
|
|
nodeName = name;
|
|
} else {
|
|
char idStr[6];
|
|
snprintf(idStr, sizeof(idStr), "%04X", (uint16_t)(node->num & 0xFFFF));
|
|
nodeName = String(idStr);
|
|
}
|
|
}
|
|
return nodeName;
|
|
}
|
|
|
|
uint32_t sinceLastSeen(meshtastic_NodeInfoLite *node)
|
|
{
|
|
uint32_t now = getTime();
|
|
uint32_t last_seen = node->last_heard;
|
|
if (last_seen == 0 || now < last_seen) {
|
|
return UINT32_MAX;
|
|
}
|
|
return now - last_seen;
|
|
}
|
|
|
|
const char *getCurrentModeTitle(int screenWidth)
|
|
{
|
|
switch (currentMode) {
|
|
case MODE_LAST_HEARD:
|
|
return "Node List";
|
|
case MODE_HOP_SIGNAL:
|
|
return (screenWidth > 128) ? "Hops/Signal" : "Hops/Sig";
|
|
case MODE_DISTANCE:
|
|
return "Distance";
|
|
default:
|
|
return "Nodes";
|
|
}
|
|
}
|
|
|
|
// Use dynamic timing based on mode
|
|
unsigned long getModeCycleIntervalMs()
|
|
{
|
|
return 3000;
|
|
}
|
|
|
|
// Calculate bearing between two lat/lon points
|
|
float calculateBearing(double lat1, double lon1, double lat2, double lon2)
|
|
{
|
|
double dLon = (lon2 - lon1) * DEG_TO_RAD;
|
|
double y = sin(dLon) * cos(lat2 * DEG_TO_RAD);
|
|
double x = cos(lat1 * DEG_TO_RAD) * sin(lat2 * DEG_TO_RAD) - sin(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * cos(dLon);
|
|
double bearing = atan2(y, x) * RAD_TO_DEG;
|
|
return fmod(bearing + 360.0, 360.0);
|
|
}
|
|
|
|
int calculateMaxScroll(int totalEntries, int visibleRows)
|
|
{
|
|
return std::max(0, (totalEntries - 1) / (visibleRows * 2));
|
|
}
|
|
|
|
void retrieveAndSortNodes(std::vector<NodeEntry> &nodeList)
|
|
{
|
|
size_t numNodes = nodeDB->getNumMeshNodes();
|
|
for (size_t i = 0; i < numNodes; i++) {
|
|
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
|
if (!node || node->num == nodeDB->getNodeNum())
|
|
continue;
|
|
|
|
NodeEntry entry;
|
|
entry.node = node;
|
|
entry.sortValue = sinceLastSeen(node);
|
|
|
|
nodeList.push_back(entry);
|
|
}
|
|
|
|
// Sort nodes: favorites first, then by last heard (most recent first)
|
|
std::sort(nodeList.begin(), nodeList.end(), [](const NodeEntry &a, const NodeEntry &b) {
|
|
bool aFav = a.node->is_favorite;
|
|
bool bFav = b.node->is_favorite;
|
|
if (aFav != bFav)
|
|
return aFav > bFav;
|
|
if (a.sortValue == 0 || a.sortValue == UINT32_MAX)
|
|
return false;
|
|
if (b.sortValue == 0 || b.sortValue == UINT32_MAX)
|
|
return true;
|
|
return a.sortValue < b.sortValue;
|
|
});
|
|
}
|
|
|
|
void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd)
|
|
{
|
|
int columnWidth = display->getWidth() / 2;
|
|
int separatorX = x + columnWidth - 1;
|
|
for (int y = yStart; y <= yEnd; y += 2) {
|
|
display->setPixel(separatorX, y);
|
|
}
|
|
}
|
|
|
|
void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, int scrollIndex, int columns, int scrollStartY)
|
|
{
|
|
if (totalEntries <= visibleNodeRows * columns)
|
|
return;
|
|
|
|
int scrollbarX = display->getWidth() - 2;
|
|
int scrollbarHeight = display->getHeight() - scrollStartY - 10;
|
|
int thumbHeight = std::max(4, (scrollbarHeight * visibleNodeRows * columns) / totalEntries);
|
|
int maxScroll = calculateMaxScroll(totalEntries, visibleNodeRows);
|
|
int thumbY = scrollStartY + (scrollIndex * (scrollbarHeight - thumbHeight)) / std::max(1, maxScroll);
|
|
|
|
for (int i = 0; i < thumbHeight; i++) {
|
|
display->setPixel(scrollbarX, thumbY + i);
|
|
}
|
|
}
|
|
|
|
// =============================
|
|
// Entry Renderers
|
|
// =============================
|
|
|
|
void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
|
{
|
|
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
|
int timeOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7);
|
|
|
|
String nodeName = getSafeNodeName(node);
|
|
|
|
char timeStr[10];
|
|
uint32_t seconds = sinceLastSeen(node);
|
|
if (seconds == 0 || seconds == UINT32_MAX) {
|
|
snprintf(timeStr, sizeof(timeStr), "?");
|
|
} else {
|
|
uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
|
|
snprintf(timeStr, sizeof(timeStr), (days > 365 ? "?" : "%d%c"),
|
|
(days ? days
|
|
: hours ? hours
|
|
: minutes),
|
|
(days ? 'd'
|
|
: hours ? 'h'
|
|
: 'm'));
|
|
}
|
|
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
display->setFont(FONT_SMALL);
|
|
display->drawString(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nodeName);
|
|
if (node->is_favorite) {
|
|
if (SCREEN_WIDTH > 128) {
|
|
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
|
} else {
|
|
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
|
}
|
|
}
|
|
|
|
int rightEdge = x + columnWidth - timeOffset;
|
|
int textWidth = display->getStringWidth(timeStr);
|
|
display->drawString(rightEdge - textWidth, y, timeStr);
|
|
}
|
|
|
|
void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
|
{
|
|
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
|
|
|
int nameMaxWidth = columnWidth - 25;
|
|
int barsOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 16 : 20) : (isLeftCol ? 15 : 19);
|
|
int hopOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 17 : 25) : (isLeftCol ? 13 : 17);
|
|
|
|
int barsXOffset = columnWidth - barsOffset;
|
|
|
|
String nodeName = getSafeNodeName(node);
|
|
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
display->setFont(FONT_SMALL);
|
|
|
|
display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nameMaxWidth, nodeName);
|
|
if (node->is_favorite) {
|
|
if (SCREEN_WIDTH > 128) {
|
|
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
|
} else {
|
|
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
|
}
|
|
}
|
|
|
|
// Draw signal strength bars
|
|
int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0;
|
|
int barWidth = 2;
|
|
int barStartX = x + barsXOffset;
|
|
int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2;
|
|
|
|
for (int b = 0; b < 4; b++) {
|
|
if (b < bars) {
|
|
int height = (b * 2);
|
|
display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height);
|
|
}
|
|
}
|
|
|
|
// Draw hop count
|
|
char hopStr[6] = "";
|
|
if (node->has_hops_away && node->hops_away > 0)
|
|
snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away);
|
|
|
|
if (hopStr[0] != '\0') {
|
|
int rightEdge = x + columnWidth - hopOffset;
|
|
int textWidth = display->getStringWidth(hopStr);
|
|
display->drawString(rightEdge - textWidth, y, hopStr);
|
|
}
|
|
}
|
|
|
|
void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
|
{
|
|
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
|
int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
|
|
|
|
String nodeName = getSafeNodeName(node);
|
|
char distStr[10] = "";
|
|
|
|
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
|
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
|
|
double lat1 = ourNode->position.latitude_i * 1e-7;
|
|
double lon1 = ourNode->position.longitude_i * 1e-7;
|
|
double lat2 = node->position.latitude_i * 1e-7;
|
|
double lon2 = node->position.longitude_i * 1e-7;
|
|
|
|
double earthRadiusKm = 6371.0;
|
|
double dLat = (lat2 - lat1) * DEG_TO_RAD;
|
|
double dLon = (lon2 - lon1) * DEG_TO_RAD;
|
|
|
|
double a =
|
|
sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2);
|
|
double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
|
double distanceKm = earthRadiusKm * c;
|
|
|
|
if (distanceKm < 1.0) {
|
|
int meters = (int)(distanceKm * 1000);
|
|
if (meters < 1000) {
|
|
snprintf(distStr, sizeof(distStr), "%dm", meters);
|
|
} else {
|
|
snprintf(distStr, sizeof(distStr), "1km");
|
|
}
|
|
} else {
|
|
int km = (int)(distanceKm + 0.5);
|
|
if (km < 1000) {
|
|
snprintf(distStr, sizeof(distStr), "%dkm", km);
|
|
} else {
|
|
snprintf(distStr, sizeof(distStr), "999");
|
|
}
|
|
}
|
|
}
|
|
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
display->setFont(FONT_SMALL);
|
|
display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nameMaxWidth, nodeName);
|
|
if (node->is_favorite) {
|
|
if (SCREEN_WIDTH > 128) {
|
|
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
|
} else {
|
|
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
|
}
|
|
}
|
|
|
|
if (strlen(distStr) > 0) {
|
|
int offset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 5 : 8);
|
|
int rightEdge = x + columnWidth - offset;
|
|
int textWidth = display->getStringWidth(distStr);
|
|
display->drawString(rightEdge - textWidth, y, distStr);
|
|
}
|
|
}
|
|
|
|
void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
|
{
|
|
switch (currentMode) {
|
|
case MODE_LAST_HEARD:
|
|
drawEntryLastHeard(display, node, x, y, columnWidth);
|
|
break;
|
|
case MODE_HOP_SIGNAL:
|
|
drawEntryHopSignal(display, node, x, y, columnWidth);
|
|
break;
|
|
case MODE_DISTANCE:
|
|
drawNodeDistance(display, node, x, y, columnWidth);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
|
{
|
|
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
|
int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
|
|
|
|
String nodeName = getSafeNodeName(node);
|
|
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
display->setFont(FONT_SMALL);
|
|
display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nameMaxWidth, nodeName);
|
|
if (node->is_favorite) {
|
|
if (SCREEN_WIDTH > 128) {
|
|
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
|
} else {
|
|
display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
|
|
}
|
|
}
|
|
}
|
|
|
|
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
|
|
double userLat, double userLon)
|
|
{
|
|
if (!nodeDB->hasValidPosition(node))
|
|
return;
|
|
|
|
double nodeLat = node->position.latitude_i * 1e-7;
|
|
double nodeLon = node->position.longitude_i * 1e-7;
|
|
float bearing = calculateBearing(userLat, userLon, nodeLat, nodeLon);
|
|
|
|
if (!config.display.compass_north_top)
|
|
bearing -= myHeading;
|
|
|
|
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
|
int arrowSize = 6;
|
|
int arrowX = x + columnWidth - (isLeftCol ? 12 : 16);
|
|
int arrowY = y + FONT_HEIGHT_SMALL / 2;
|
|
|
|
CompassRenderer::drawArrowToNode(display, arrowX, arrowY, arrowSize, bearing);
|
|
}
|
|
|
|
// =============================
|
|
// Main Screen Functions
|
|
// =============================
|
|
|
|
void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
|
|
EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon)
|
|
{
|
|
const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
|
|
const int rowYOffset = FONT_HEIGHT_SMALL - 3;
|
|
|
|
int columnWidth = display->getWidth() / 2;
|
|
|
|
display->clear();
|
|
|
|
// Draw the battery/time header
|
|
graphics::drawCommonHeader(display, x, y);
|
|
|
|
// Draw the centered title within the header
|
|
const int highlightHeight = COMMON_HEADER_HEIGHT;
|
|
const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
|
|
const int centerX = x + SCREEN_WIDTH / 2;
|
|
|
|
display->setFont(FONT_SMALL);
|
|
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
|
|
|
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED)
|
|
display->setColor(BLACK);
|
|
|
|
display->drawString(centerX, textY, title);
|
|
if (config.display.heading_bold)
|
|
display->drawString(centerX + 1, textY, title);
|
|
|
|
display->setColor(WHITE);
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
|
|
// Space below header
|
|
y += COMMON_HEADER_HEIGHT;
|
|
|
|
// Fetch and display sorted node list
|
|
std::vector<NodeEntry> nodeList;
|
|
retrieveAndSortNodes(nodeList);
|
|
|
|
int totalEntries = nodeList.size();
|
|
int totalRowsAvailable = (display->getHeight() - y) / rowYOffset;
|
|
#ifdef USE_EINK
|
|
totalRowsAvailable -= 1;
|
|
#endif
|
|
int visibleNodeRows = totalRowsAvailable;
|
|
int totalColumns = 2;
|
|
|
|
int startIndex = scrollIndex * visibleNodeRows * totalColumns;
|
|
int endIndex = std::min(startIndex + visibleNodeRows * totalColumns, totalEntries);
|
|
|
|
int yOffset = 0;
|
|
int col = 0;
|
|
int lastNodeY = y;
|
|
int shownCount = 0;
|
|
int rowCount = 0;
|
|
|
|
for (int i = startIndex; i < endIndex; ++i) {
|
|
int xPos = x + (col * columnWidth);
|
|
int yPos = y + yOffset;
|
|
renderer(display, nodeList[i].node, xPos, yPos, columnWidth);
|
|
|
|
if (extras) {
|
|
extras(display, nodeList[i].node, xPos, yPos, columnWidth, heading, lat, lon);
|
|
}
|
|
|
|
lastNodeY = std::max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
|
|
yOffset += rowYOffset;
|
|
shownCount++;
|
|
rowCount++;
|
|
|
|
if (rowCount >= totalRowsAvailable) {
|
|
yOffset = 0;
|
|
rowCount = 0;
|
|
col++;
|
|
if (col > (totalColumns - 1))
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Draw column separator
|
|
if (shownCount > 0) {
|
|
const int firstNodeY = y + 3;
|
|
drawColumnSeparator(display, x, firstNodeY, lastNodeY);
|
|
}
|
|
|
|
const int scrollStartY = y + 3;
|
|
drawScrollbar(display, visibleNodeRows, totalEntries, scrollIndex, 2, scrollStartY);
|
|
}
|
|
|
|
// =============================
|
|
// Screen Frame Functions
|
|
// =============================
|
|
|
|
#ifndef USE_EINK
|
|
void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
// Static variables to track mode and duration
|
|
static NodeListMode lastRenderedMode = MODE_COUNT;
|
|
static unsigned long modeStartTime = 0;
|
|
|
|
unsigned long now = millis();
|
|
|
|
// On very first call (on boot or state enter)
|
|
if (lastRenderedMode == MODE_COUNT) {
|
|
currentMode = MODE_LAST_HEARD;
|
|
modeStartTime = now;
|
|
}
|
|
|
|
// Time to switch to next mode?
|
|
if (now - modeStartTime >= getModeCycleIntervalMs()) {
|
|
currentMode = static_cast<NodeListMode>((currentMode + 1) % MODE_COUNT);
|
|
modeStartTime = now;
|
|
}
|
|
|
|
// Render screen based on currentMode
|
|
const char *title = getCurrentModeTitle(display->getWidth());
|
|
drawNodeListScreen(display, state, x, y, title, drawEntryDynamic);
|
|
|
|
// Track the last mode to avoid reinitializing modeStartTime
|
|
lastRenderedMode = currentMode;
|
|
}
|
|
#endif
|
|
|
|
#ifdef USE_EINK
|
|
void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
const char *title = "Node List";
|
|
drawNodeListScreen(display, state, x, y, title, drawEntryLastHeard);
|
|
}
|
|
|
|
void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
const char *title = "Hops/Signal";
|
|
drawNodeListScreen(display, state, x, y, title, drawEntryHopSignal);
|
|
}
|
|
|
|
void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
const char *title = "Distance";
|
|
drawNodeListScreen(display, state, x, y, title, drawNodeDistance);
|
|
}
|
|
#endif
|
|
|
|
void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
float heading = 0;
|
|
bool validHeading = false;
|
|
double lat = 0;
|
|
double lon = 0;
|
|
|
|
#if HAS_GPS
|
|
if (screen->hasHeading()) {
|
|
heading = screen->getHeading(); // degrees
|
|
validHeading = true;
|
|
} else {
|
|
heading = screen->estimatedHeading(lat, lon);
|
|
validHeading = !isnan(heading);
|
|
}
|
|
#endif
|
|
|
|
if (!validHeading)
|
|
return;
|
|
|
|
drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon);
|
|
}
|
|
|
|
void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
// Cache favorite nodes for the current frame only, to save computation
|
|
static std::vector<meshtastic_NodeInfoLite *> favoritedNodes;
|
|
static int prevFrame = -1;
|
|
|
|
// Only rebuild favorites list if we're on a new frame
|
|
if (state->currentFrame != prevFrame) {
|
|
prevFrame = state->currentFrame;
|
|
favoritedNodes.clear();
|
|
size_t total = nodeDB->getNumMeshNodes();
|
|
for (size_t i = 0; i < total; i++) {
|
|
meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
|
// Skip nulls and ourself
|
|
if (!n || n->num == nodeDB->getNodeNum())
|
|
continue;
|
|
if (n->is_favorite)
|
|
favoritedNodes.push_back(n);
|
|
}
|
|
// Keep a stable, consistent display order
|
|
std::sort(favoritedNodes.begin(), favoritedNodes.end(),
|
|
[](meshtastic_NodeInfoLite *a, meshtastic_NodeInfoLite *b) { return a->num < b->num; });
|
|
}
|
|
if (favoritedNodes.empty())
|
|
return;
|
|
|
|
// Only display if index is valid
|
|
int nodeIndex = state->currentFrame - (screen->frameCount - favoritedNodes.size());
|
|
if (nodeIndex < 0 || nodeIndex >= (int)favoritedNodes.size())
|
|
return;
|
|
|
|
meshtastic_NodeInfoLite *node = favoritedNodes[nodeIndex];
|
|
if (!node || node->num == nodeDB->getNodeNum() || !node->is_favorite)
|
|
return;
|
|
|
|
display->clear();
|
|
|
|
// Draw battery/time/mail header (common across screens)
|
|
graphics::drawCommonHeader(display, x, y);
|
|
|
|
// Draw the short node name centered at the top, with bold shadow if set
|
|
const int highlightHeight = FONT_HEIGHT_SMALL - 1;
|
|
const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
|
|
const int centerX = x + SCREEN_WIDTH / 2;
|
|
const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node";
|
|
|
|
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED)
|
|
display->setColor(BLACK);
|
|
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
|
display->setFont(FONT_SMALL);
|
|
display->drawString(centerX, textY, shortName);
|
|
if (config.display.heading_bold)
|
|
display->drawString(centerX + 1, textY, shortName);
|
|
|
|
display->setColor(WHITE);
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
display->setFont(FONT_SMALL);
|
|
|
|
// Dynamic row stacking with predefined Y positions
|
|
const int yPositions[5] = {moreCompactFirstLine, moreCompactSecondLine, moreCompactThirdLine, moreCompactFourthLine,
|
|
moreCompactFifthLine};
|
|
int line = 0;
|
|
|
|
// 1. Long Name (always try to show first)
|
|
const char *username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr;
|
|
if (username && line < 5) {
|
|
display->drawString(x, yPositions[line++], username);
|
|
}
|
|
|
|
// 2. Signal and Hops (combined on one line, if available)
|
|
char signalHopsStr[32] = "";
|
|
bool haveSignal = false;
|
|
int percentSignal = clamp((int)((node->snr + 10) * 5), 0, 100);
|
|
const char *signalLabel = " Sig";
|
|
|
|
// If SNR looks reasonable, show signal
|
|
if ((int)((node->snr + 10) * 5) >= 0 && node->snr > -100) {
|
|
snprintf(signalHopsStr, sizeof(signalHopsStr), "%s: %d%%", signalLabel, percentSignal);
|
|
haveSignal = true;
|
|
}
|
|
// If hops is valid (>0), show right after signal
|
|
if (node->hops_away > 0) {
|
|
size_t len = strlen(signalHopsStr);
|
|
if (haveSignal) {
|
|
snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [%d %s]", node->hops_away,
|
|
(node->hops_away == 1 ? "Hop" : "Hops"));
|
|
} else {
|
|
snprintf(signalHopsStr, sizeof(signalHopsStr), "[%d %s]", node->hops_away, (node->hops_away == 1 ? "Hop" : "Hops"));
|
|
}
|
|
}
|
|
if (signalHopsStr[0] && line < 5) {
|
|
display->drawString(x, yPositions[line++], signalHopsStr);
|
|
}
|
|
|
|
// 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;
|
|
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) {
|
|
display->drawString(x, yPositions[line++], seenStr);
|
|
}
|
|
|
|
// 4. Uptime (only show if metric is present)
|
|
char uptimeStr[32] = "";
|
|
if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) {
|
|
uint32_t uptime = node->device_metrics.uptime_seconds;
|
|
uint32_t days = uptime / 86400;
|
|
uint32_t hours = (uptime % 86400) / 3600;
|
|
uint32_t mins = (uptime % 3600) / 60;
|
|
|
|
if (days > 0) {
|
|
snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %dd %dh", days, hours);
|
|
} else if (hours > 0) {
|
|
snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %dh %dm", hours, mins);
|
|
} else {
|
|
snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %dm", mins);
|
|
}
|
|
}
|
|
if (uptimeStr[0] && line < 5) {
|
|
display->drawString(x, yPositions[line++], uptimeStr);
|
|
}
|
|
|
|
// 5. Distance (only if both nodes have GPS position)
|
|
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
|
char distStr[24] = "";
|
|
bool haveDistance = false;
|
|
|
|
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
|
|
double lat1 = ourNode->position.latitude_i * 1e-7;
|
|
double lon1 = ourNode->position.longitude_i * 1e-7;
|
|
double lat2 = node->position.latitude_i * 1e-7;
|
|
double lon2 = node->position.longitude_i * 1e-7;
|
|
|
|
double earthRadiusKm = 6371.0;
|
|
double dLat = (lat2 - lat1) * DEG_TO_RAD;
|
|
double dLon = (lon2 - lon1) * DEG_TO_RAD;
|
|
|
|
double a =
|
|
sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2);
|
|
double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
|
double distanceKm = earthRadiusKm * c;
|
|
|
|
// Format distance appropriately
|
|
if (distanceKm < 1.0) {
|
|
double miles = distanceKm * 0.621371;
|
|
if (miles < 0.1) {
|
|
int feet = (int)(miles * 5280);
|
|
if (feet > 0 && feet < 1000) {
|
|
snprintf(distStr, sizeof(distStr), " Distance: %dft", feet);
|
|
haveDistance = true;
|
|
} else if (feet >= 1000) {
|
|
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);
|
|
haveDistance = true;
|
|
}
|
|
}
|
|
} else {
|
|
int km = (int)(distanceKm + 0.5);
|
|
if (km > 0 && km < 1000) {
|
|
snprintf(distStr, sizeof(distStr), " Distance: %dkm", km);
|
|
haveDistance = true;
|
|
}
|
|
}
|
|
}
|
|
// Only display if we actually have a value!
|
|
if (haveDistance && distStr[0] && line < 5) {
|
|
display->drawString(x, yPositions[line++], distStr);
|
|
}
|
|
|
|
// Compass rendering for different screen orientations
|
|
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
|
// Landscape: side-aligned compass
|
|
bool showCompass = false;
|
|
if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
|
|
showCompass = true;
|
|
}
|
|
if (showCompass) {
|
|
const int16_t topY = compactFirstLine;
|
|
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1);
|
|
const int16_t usableHeight = bottomY - topY - 5;
|
|
int16_t compassRadius = usableHeight / 2;
|
|
if (compassRadius < 8)
|
|
compassRadius = 8;
|
|
const int16_t compassDiam = compassRadius * 2;
|
|
const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8;
|
|
const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
|
|
|
const auto &op = ourNode->position;
|
|
float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
|
|
: screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
|
|
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading);
|
|
|
|
const auto &p = node->position;
|
|
float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
|
if (!config.display.compass_north_top)
|
|
bearing -= myHeading;
|
|
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing);
|
|
|
|
display->drawCircle(compassX, compassY, compassRadius);
|
|
}
|
|
} else {
|
|
// Portrait: bottom-centered compass
|
|
bool showCompass = false;
|
|
if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
|
|
showCompass = true;
|
|
}
|
|
if (showCompass) {
|
|
int yBelowContent = (line > 0 && line <= 5) ? (yPositions[line - 1] + FONT_HEIGHT_SMALL + 2) : moreCompactFirstLine;
|
|
|
|
const int margin = 4;
|
|
#if defined(USE_EINK)
|
|
const int iconSize = (SCREEN_WIDTH > 128) ? 16 : 8;
|
|
const int navBarHeight = iconSize + 6;
|
|
#else
|
|
const int navBarHeight = 0;
|
|
#endif
|
|
int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin;
|
|
|
|
if (availableHeight < FONT_HEIGHT_SMALL * 2)
|
|
return;
|
|
|
|
int compassRadius = availableHeight / 2;
|
|
if (compassRadius < 8)
|
|
compassRadius = 8;
|
|
if (compassRadius * 2 > SCREEN_WIDTH - 16)
|
|
compassRadius = (SCREEN_WIDTH - 16) / 2;
|
|
|
|
int compassX = x + SCREEN_WIDTH / 2;
|
|
int compassY = yBelowContent + availableHeight / 2;
|
|
|
|
const auto &op = ourNode->position;
|
|
float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
|
|
: screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
|
|
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading);
|
|
|
|
const auto &p = node->position;
|
|
float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
|
if (!config.display.compass_north_top)
|
|
bearing -= myHeading;
|
|
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
|
|
|
|
display->drawCircle(compassX, compassY, compassRadius);
|
|
}
|
|
}
|
|
}
|
|
|
|
} // namespace NodeListRenderer
|
|
} // namespace graphics
|