Compare commits

..

10 Commits

Author SHA1 Message Date
Jason P
44efc9b58c Merge branch 'develop' into baseui_statusmessage 2026-02-06 16:13:56 -06:00
Jason P
34314d16ac Merge branch 'develop' into baseui_statusmessage 2026-02-04 11:45:40 -06:00
Jason P
6426e2598b Merge branch 'develop' into baseui_statusmessage 2026-02-04 08:57:48 -06:00
Jason P
0d57a49b51 Merge branch 'develop' into baseui_statusmessage 2026-02-03 09:19:43 -06:00
Jason P
d8a0b6a737 Reduce MAX_RECENT_STATUSMESSAGES to 5 to meet memory usage targets 2026-02-03 07:43:45 -06:00
Jason P
697dd2b5b2 Truncate overflow on Favorite frame 2026-02-02 14:26:15 -06:00
Jason P
0b8b757fb0 Rename variable, set max status to 20, added Node List View. 2026-02-02 12:00:42 -06:00
Jason P
62f897eab3 Change drawNodeInfo to drawFavoriteNode 2026-02-01 21:39:44 -06:00
Jason P
523906d031 Merge branch 'develop' into baseui_statusmessage 2026-02-01 19:10:29 -06:00
Jason P
78f29c0f87 Work through implementation of Status Message 2026-02-01 17:27:59 -06:00
18 changed files with 217 additions and 396 deletions

View File

@@ -59,14 +59,8 @@ BuildRequires: pkgconfig(libbsd-overlay)
Requires: systemd-udev
# Declare that this package provides the user/group it creates in %pre
# Required for Fedora 43+ which tracks users/groups as RPM dependencies
Provides: user(%{meshtasticd_user})
Provides: group(%{meshtasticd_user})
Provides: group(spi)
%description
Meshtastic daemon. Meshtastic is an off-grid
Meshtastic daemon for controlling Meshtastic devices. Meshtastic is an off-grid
text communication platform that uses inexpensive LoRa radios.
%prep
@@ -157,7 +151,6 @@ fi
%license LICENSE
%doc README.md
%{_bindir}/meshtasticd
%{_bindir}/meshtasticd-start.sh
%dir %{_localstatedir}/lib/meshtasticd
%{_udevrulesdir}/99-meshtasticd-udev.rules
%dir %{_sysconfdir}/meshtasticd

View File

@@ -692,9 +692,7 @@ bool Power::setup()
bool found = false;
if (axpChipInit()) {
found = true;
} else if (cw2015Init()) {
found = true;
} else if (max17048Init()) {
} else if (lipoInit()) {
found = true;
} else if (lipoChargerInit()) {
found = true;
@@ -1323,7 +1321,7 @@ bool Power::axpChipInit()
/**
* Wrapper class for an I2C MAX17048 Lipo battery sensor.
*/
class MAX17048BatteryLevel : public HasBatteryLevel
class LipoBatteryLevel : public HasBatteryLevel
{
private:
MAX17048Singleton *max17048 = nullptr;
@@ -1371,18 +1369,18 @@ class MAX17048BatteryLevel : public HasBatteryLevel
virtual bool isCharging() override { return max17048->isBatteryCharging(); }
};
MAX17048BatteryLevel max17048Level;
LipoBatteryLevel lipoLevel;
/**
* Init the Lipo battery level sensor
*/
bool Power::max17048Init()
bool Power::lipoInit()
{
bool result = max17048Level.runOnce();
LOG_DEBUG("Power::max17048Init lipo sensor is %s", result ? "ready" : "not ready yet");
bool result = lipoLevel.runOnce();
LOG_DEBUG("Power::lipoInit lipo sensor is %s", result ? "ready" : "not ready yet");
if (!result)
return false;
batteryLevel = &max17048Level;
batteryLevel = &lipoLevel;
return true;
}
@@ -1390,88 +1388,7 @@ bool Power::max17048Init()
/**
* The Lipo battery level sensor is unavailable - default to AnalogBatteryLevel
*/
bool Power::max17048Init()
{
return false;
}
#endif
#if !MESHTASTIC_EXCLUDE_I2C && HAS_CW2015
class CW2015BatteryLevel : public AnalogBatteryLevel
{
public:
/**
* Battery state of charge, from 0 to 100 or -1 for unknown
*/
virtual int getBatteryPercent() override
{
int data = -1;
Wire.beginTransmission(CW2015_ADDR);
Wire.write(0x04);
if (Wire.endTransmission() == 0) {
if (Wire.requestFrom(CW2015_ADDR, (uint8_t)1)) {
data = Wire.read();
}
}
return data;
}
/**
* The raw voltage of the battery in millivolts, or NAN if unknown
*/
virtual uint16_t getBattVoltage() override
{
uint16_t mv = 0;
Wire.beginTransmission(CW2015_ADDR);
Wire.write(0x02);
if (Wire.endTransmission() == 0) {
if (Wire.requestFrom(CW2015_ADDR, (uint8_t)2)) {
mv = Wire.read();
mv <<= 8;
mv |= Wire.read();
// Voltage is read in 305uV units, convert to mV
mv = mv * 305 / 1000;
}
}
return mv;
}
};
CW2015BatteryLevel cw2015Level;
/**
* Init the CW2015 battery level sensor
*/
bool Power::cw2015Init()
{
Wire.beginTransmission(CW2015_ADDR);
uint8_t getInfo[] = {0x0a, 0x00};
Wire.write(getInfo, 2);
Wire.endTransmission();
delay(10);
Wire.beginTransmission(CW2015_ADDR);
Wire.write(0x00);
bool result = false;
if (Wire.endTransmission() == 0) {
if (Wire.requestFrom(CW2015_ADDR, (uint8_t)1)) {
uint8_t data = Wire.read();
LOG_DEBUG("CW2015 init read data: 0x%x", data);
if (data == 0x73) {
result = true;
batteryLevel = &cw2015Level;
}
}
}
return result;
}
#else
/**
* The CW2015 battery level sensor is unavailable - default to AnalogBatteryLevel
*/
bool Power::cw2015Init()
bool Power::lipoInit()
{
return false;
}

View File

@@ -233,7 +233,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#define NAU7802_ADDR 0x2A
#define MAX30102_ADDR 0x57
#define SCD4X_ADDR 0x62
#define CW2015_ADDR 0x62
#define MLX90614_ADDR_DEF 0x5A
#define CGRADSENS_ADDR 0x66
#define LTR390UV_ADDR 0x53

View File

@@ -88,8 +88,7 @@ class ScanI2C
BH1750,
DA217,
CHSC6X,
CST226SE,
CW2015
CST226SE
} DeviceType;
// typedef uint8_t DeviceAddress;

View File

@@ -541,17 +541,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
SCAN_SIMPLE_CASE(BHI260AP_ADDR, BHI260AP, "BHI260AP", (uint8_t)addr.address);
case SCD4X_ADDR: {
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x8), 1);
if (registerValue == 0x18) {
logFoundDevice("CW2015", (uint8_t)addr.address);
type = CW2015;
} else {
logFoundDevice("SCD4X", (uint8_t)addr.address);
type = SCD4X;
}
break;
}
SCAN_SIMPLE_CASE(SCD4X_ADDR, SCD4X, "SCD4X", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(BMM150_ADDR, BMM150, "BMM150", (uint8_t)addr.address);
#ifdef HAS_TPS65233
SCAN_SIMPLE_CASE(TPS65233_ADDR, TPS65233, "TPS65233", (uint8_t)addr.address);

View File

@@ -1175,7 +1175,7 @@ void Screen::setFrames(FrameFocus focus)
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) {
favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo);
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
}
}
@@ -1204,7 +1204,7 @@ void Screen::setFrames(FrameFocus focus)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list just changed)
prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed)
// Focus on a specific frame, in the frame set we just created
switch (focus) {

View File

@@ -422,52 +422,44 @@ const int *getTextPositions(OLEDDisplay *display)
return textPositions;
}
static inline bool isAPIConnected(uint8_t state)
{
static constexpr bool connectedStates[] = {
/* STATE_NONE */ false,
/* STATE_BLE */ true,
/* STATE_WIFI */ true,
/* STATE_SERIAL */ true,
/* STATE_PACKET */ true,
/* STATE_HTTP */ true,
/* STATE_ETH */ true,
};
return state < sizeof(connectedStates) ? connectedStates[state] : false;
}
// *************************
// * Common Footer Drawing *
// *************************
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
{
if (!isAPIConnected(service->api_state))
return;
bool drawConnectionState = false;
if (service->api_state == service->STATE_BLE || service->api_state == service->STATE_WIFI ||
service->api_state == service->STATE_SERIAL || service->api_state == service->STATE_PACKET ||
service->api_state == service->STATE_HTTP || service->api_state == service->STATE_ETH) {
drawConnectionState = true;
}
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
display->setColor(BLACK);
display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale),
(connection_icon_height * scale) + (2 * scale));
display->setColor(WHITE);
if (currentResolution == ScreenResolution::High) {
const int bytesPerRow = (connection_icon_width + 7) / 8;
int iconX = 0;
int iconY = SCREEN_HEIGHT - (connection_icon_height * 2);
if (drawConnectionState) {
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
display->setColor(BLACK);
display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale),
(connection_icon_height * scale) + (2 * scale));
display->setColor(WHITE);
if (currentResolution == ScreenResolution::High) {
const int bytesPerRow = (connection_icon_width + 7) / 8;
int iconX = 0;
int iconY = SCREEN_HEIGHT - (connection_icon_height * 2);
for (int yy = 0; yy < connection_icon_height; ++yy) {
const uint8_t *rowPtr = connection_icon + yy * bytesPerRow;
for (int xx = 0; xx < connection_icon_width; ++xx) {
const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3));
const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first
if (byteVal & bitMask) {
display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, scale);
for (int yy = 0; yy < connection_icon_height; ++yy) {
const uint8_t *rowPtr = connection_icon + yy * bytesPerRow;
for (int xx = 0; xx < connection_icon_width; ++xx) {
const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3));
const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first
if (byteVal & bitMask) {
display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, scale);
}
}
}
}
} else {
display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
connection_icon);
} else {
display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
connection_icon);
}
}
}

View File

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

View File

@@ -429,10 +429,6 @@ void VirtualKeyboard::drawKey(OLEDDisplay *display, const VirtualKey &key, bool
c = c - 'a' + 'A';
}
keyText = (key.character == ' ' || key.character == '_') ? "_" : std::string(1, c);
// Show the common "/" pairing next to "?" like on a real keyboard
if (key.type == VK_CHAR && key.character == '?') {
keyText = "?/";
}
}
int textWidth = display->getStringWidth(keyText.c_str());
@@ -522,13 +518,9 @@ char VirtualKeyboard::getCharForKey(const VirtualKey &key, bool isLongPress)
char c = key.character;
// Long-press: letters become uppercase; for "?" provide "/" like a typical keyboard
if (isLongPress) {
if (c >= 'a' && c <= 'z') {
c = (char)(c - 'a' + 'A');
} else if (c == '?') {
c = '/';
}
// Long-press: only keep letter lowercase->uppercase conversion; remove other symbol mappings
if (isLongPress && c >= 'a' && c <= 'z') {
c = (char)(c - 'a' + 'A');
}
return c;

View File

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

View File

@@ -3,6 +3,9 @@
#include "CompassRenderer.h"
#include "NodeDB.h"
#include "NodeListRenderer.h"
#if !MESHTASTIC_EXCLUDE_STATUS
#include "modules/StatusMessageModule.h"
#endif
#include "UIRenderer.h"
#include "gps/GeoCoord.h"
#include "gps/RTC.h" // for getTime() function
@@ -90,8 +93,41 @@ const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
// 1) Choose target candidate (long vs short) only if present
const char *raw = nullptr;
if (node && node->has_user) {
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
#if !MESHTASTIC_EXCLUDE_STATUS
// If long-name mode is enabled, and we have a recent status for this node,
// prefer "(short_name) statusText" as the raw candidate.
std::string composedFromStatus;
if (config.display.use_long_node_name && node && node->has_user && statusMessageModule) {
const auto &recent = statusMessageModule->getRecentReceived();
const StatusMessageModule::RecentStatus *found = nullptr;
for (auto it = recent.rbegin(); it != recent.rend(); ++it) {
if (it->fromNodeId == node->num && !it->statusText.empty()) {
found = &(*it);
break;
}
}
if (found) {
const char *shortName = node->user.short_name;
composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + found->statusText.size());
composedFromStatus += "(";
if (shortName && *shortName) {
composedFromStatus += shortName;
}
composedFromStatus += ") ";
composedFromStatus += found->statusText;
raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string
}
}
#endif
// If we didn't compose from status, use normal long/short selection
if (!raw) {
if (node && node->has_user) {
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
}
}
// 2) Sanitize (empty if raw is null/empty)

View File

@@ -4,6 +4,9 @@
#include "GPSStatus.h"
#include "NodeDB.h"
#include "NodeListRenderer.h"
#if !MESHTASTIC_EXCLUDE_STATUS
#include "modules/StatusMessageModule.h"
#endif
#include "UIRenderer.h"
#include "airtime.h"
#include "gps/GeoCoord.h"
@@ -287,7 +290,7 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
// **********************
// * Favorite Node Info *
// **********************
void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y)
void UIRenderer::drawFavoriteNode(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y)
{
if (favoritedNodes.empty())
return;
@@ -313,7 +316,7 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
// === Create the shortName and title string ===
const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node";
char titlestr[32] = {0};
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
snprintf(titlestr, sizeof(titlestr), "Fav: %s", shortName);
// === Draw battery/time/mail header (common across screens) ===
graphics::drawCommonHeader(display, x, y, titlestr);
@@ -341,168 +344,95 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
display->drawString(x, getTextPositions(display)[line++], usernameStr.c_str());
}
// === 2. Signal and Hops (combined on one line, if available) ===
char signalHopsStr[32] = "";
bool haveSignal = false;
int bars = 0;
#if !MESHTASTIC_EXCLUDE_STATUS
// === Optional: Last received StatusMessage line for this node ===
// Display it directly under the username line (if we have one).
if (statusMessageModule) {
const auto &recent = statusMessageModule->getRecentReceived();
const StatusMessageModule::RecentStatus *found = nullptr;
// 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;
}
};
// 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 ---
// Only show signal if we have valid SNR
if (snr > -100 && snr != 0) {
snprintf(signalHopsStr, sizeof(signalHopsStr), " Sig:%s", qualityLabel);
haveSignal = true;
}
if (node->hops_away > 0) {
size_t len = strlen(signalHopsStr);
if (haveSignal) {
snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [#]");
} else {
snprintf(signalHopsStr, sizeof(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';
// Search newest-to-oldest
for (auto it = recent.rbegin(); it != recent.rend(); ++it) {
if (it->fromNodeId == node->num && !it->statusText.empty()) {
found = &(*it);
break;
}
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;
if (found) {
std::string statusLine = std::string(" Status: ") + found->statusText;
{
const int screenW = display->getWidth();
const int ellipseW = display->getStringWidth("...");
int w = display->getStringWidth(statusLine.c_str());
// 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;
// Only do work if it overflows
if (w > screenW) {
bool truncated = false;
if (ellipseW > screenW) {
statusLine.clear();
} else {
while (!statusLine.empty()) {
// remove one char (byte) at a time
statusLine.pop_back();
truncated = true;
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);
// Measure candidate with ellipsis appended
std::string candidate = statusLine + "...";
if (display->getStringWidth(candidate.c_str()) <= screenW) {
statusLine = std::move(candidate);
break;
}
}
if (statusLine.empty() && ellipseW <= screenW) {
statusLine = "...";
}
}
}
}
curX += (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap) + 2;
display->drawString(x, getTextPositions(display)[line++], statusLine.c_str());
}
}
#endif
// Draw hops AFTER the bars as: [ number + hop icon ]
if (hopPart && node->hops_away > 0) {
// === 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);
// open bracket
display->drawString(curX, yPos, "[");
curX += display->getStringWidth("[") + 1;
// Always use "Sig" for the label
const char *signalLabel = " Sig";
// 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, "]");
// --- 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);
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"));
} else {
snprintf(signalHopsStr, sizeof(signalHopsStr), "[%d %s]", node->hops_away, (node->hops_away == 1 ? "Hop" : "Hops"));
}
}
if (signalHopsStr[0] && line < 5) {
display->drawString(x, getTextPositions(display)[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;
// 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),
@@ -510,16 +440,16 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
: hours ? 'h'
: 'm'));
}
if (seenStr[0]) {
if (seenStr[0] && line < 5) {
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));
}
if (uptimeStr[0]) {
if (uptimeStr[0] && line < 5) {
display->drawString(x, getTextPositions(display)[line++], uptimeStr);
}
@@ -546,16 +476,16 @@ 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;
}
}
@@ -563,74 +493,26 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
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;
}
}
}
}
if (haveDistance && distStr[0]) {
// Only display if we actually have a value!
if (haveDistance && distStr[0] && line < 5) {
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) {
bool hasPct = node->device_metrics.has_battery_level;
bool hasVolt = node->device_metrics.has_voltage && node->device_metrics.voltage > 0.001f;
int pct = 0;
float volt = 0.0f;
if (hasPct) {
pct = (int)node->device_metrics.battery_level;
}
if (hasVolt) {
volt = node->device_metrics.voltage;
}
if (hasPct && pct > 0 && pct <= 100) {
// Normal battery percentage
if (hasVolt) {
snprintf(batLine, sizeof(batLine), " Bat:%d%% (%.2fV)", pct, volt);
} else {
snprintf(batLine, sizeof(batLine), " Bat:%d%%", pct);
}
haveBatLine = true;
} else if (hasPct && pct > 100) {
// Plugged in
if (hasVolt) {
snprintf(batLine, sizeof(batLine), " Plugged In (%.2fV)", volt);
} else {
snprintf(batLine, sizeof(batLine), " Plugged In");
}
haveBatLine = true;
} else if (!hasPct && hasVolt) {
// Voltage only
snprintf(batLine, sizeof(batLine), " Bat:%.2fV", volt);
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;
@@ -765,7 +647,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);
@@ -1156,6 +1038,7 @@ 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) {
@@ -1200,10 +1083,10 @@ 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);
getUptimeStr(delta, "Last", uptimeStr, sizeof(uptimeStr), true);
#endif
display->drawString(0, getTextPositions(display)[line++], uptimeStr);

View File

@@ -49,7 +49,7 @@ class UIRenderer
// Navigation bar overlay
static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y);
static void drawFavoriteNode(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y);
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);

View File

@@ -83,12 +83,6 @@ 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

View File

@@ -93,17 +93,19 @@ int32_t StatusLEDModule::runOnce()
my_interval = 250;
if (POWER_LED_starttime + 2000 < millis()) {
doing_fast_blink = false;
CHARGE_LED_state = LED_STATE_OFF;
}
}
}
if (power_state != charging && power_state != charged && !doing_fast_blink) {
if (CHARGE_LED_state == LED_STATE_ON) {
} else {
CHARGE_LED_state = LED_STATE_OFF;
}
} else {
if (doing_fast_blink) {
CHARGE_LED_state = LED_STATE_OFF;
doing_fast_blink = false;
my_interval = 999;
} else {
CHARGE_LED_state = LED_STATE_ON;
doing_fast_blink = true;
my_interval = 1;
}
}

View File

@@ -29,10 +29,23 @@ int32_t StatusMessageModule::runOnce()
ProcessMessage StatusMessageModule::handleReceived(const meshtastic_MeshPacket &mp)
{
if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
meshtastic_StatusMessage incomingMessage;
meshtastic_StatusMessage incomingMessage = meshtastic_StatusMessage_init_zero;
if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, meshtastic_StatusMessage_fields,
&incomingMessage)) {
LOG_INFO("Received a NodeStatus message %s", incomingMessage.status);
RecentStatus entry;
entry.fromNodeId = mp.from;
entry.statusText = incomingMessage.status;
recentReceived.push_back(std::move(entry));
// Keep only last MAX_RECENT_STATUSMESSAGES
if (recentReceived.size() > MAX_RECENT_STATUSMESSAGES) {
recentReceived.erase(recentReceived.begin()); // drop oldest
}
}
}
return ProcessMessage::CONTINUE;

View File

@@ -2,10 +2,11 @@
#if !MESHTASTIC_EXCLUDE_STATUS
#include "SinglePortModule.h"
#include "configuration.h"
#include <string>
#include <vector>
class StatusMessageModule : public SinglePortModule, private concurrency::OSThread
{
public:
/** Constructor
* name is for debugging output
@@ -19,16 +20,28 @@ class StatusMessageModule : public SinglePortModule, private concurrency::OSThre
this->setInterval(1000 * 12 * 60 * 60);
}
// TODO: If we have a string, set the initial delay (15 minutes maybe)
// Keep vector from reallocating as we fill up to MAX_RECENT_STATUSMESSAGES
recentReceived.reserve(MAX_RECENT_STATUSMESSAGES);
}
virtual int32_t runOnce() override;
struct RecentStatus {
uint32_t fromNodeId; // mp.from
std::string statusText; // incomingMessage.status
};
const std::vector<RecentStatus> &getRecentReceived() const { return recentReceived; }
protected:
/** Called to handle a particular incoming message
*/
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
private:
static constexpr size_t MAX_RECENT_STATUSMESSAGES = 5;
std::vector<RecentStatus> recentReceived;
};
extern StatusMessageModule *statusMessageModule;

View File

@@ -103,10 +103,8 @@ class Power : private concurrency::OSThread
bool axpChipInit();
/// Setup a simple ADC input based battery sensor
bool analogInit();
/// Setup cw2015 battery level sensor
bool cw2015Init();
/// Setup a 17048 battery level sensor
bool max17048Init();
/// Setup a Lipo battery level sensor
bool lipoInit();
/// Setup a Lipo charger
bool lipoChargerInit();
/// Setup a meshSolar battery sensor