diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 4bf778eb9..ec3fff416 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -59,6 +59,9 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa sm.dest = packet.decoded.dest; sm.type = MessageType::DM_TO_US; } + + // Outgoing messages start as UNKNOWN until ACK/NACK arrives + sm.ackStatus = AckStatus::UNKNOWN; } else { // Normal incoming sm.sender = packet.from; @@ -72,6 +75,9 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa sm.dest = NODENUM_BROADCAST; // fallback sm.type = MessageType::BROADCAST; } + + // Received messages don’t wait for ACK mark as ACKED + sm.ackStatus = AckStatus::ACKED; } addLiveMessage(sm); @@ -103,6 +109,9 @@ void MessageStore::addFromString(uint32_t sender, uint8_t channelIndex, const st sm.dest = NODENUM_BROADCAST; sm.type = MessageType::BROADCAST; + // Manual/outgoing messages start as UNKNOWN until ACK/NACK arrives + sm.ackStatus = AckStatus::UNKNOWN; + addLiveMessage(sm); } @@ -131,8 +140,12 @@ void MessageStore::saveToFlash() f.write((uint8_t *)&m.dest, sizeof(m.dest)); f.write((uint8_t *)m.text.c_str(), std::min(static_cast(MAX_MESSAGE_SIZE), m.text.size())); f.write('\0'); // null terminator + uint8_t bootFlag = m.isBootRelative ? 1 : 0; f.write(&bootFlag, 1); // persist boot-relative flag + + uint8_t statusByte = static_cast(m.ackStatus); + f.write(&statusByte, 1); // persist ackStatus } spiLock->unlock(); @@ -189,6 +202,18 @@ void MessageStore::loadFromFlash() m.isBootRelative = (m.timestamp < 60u * 60u * 24u * 7u); } + // Try to read ackStatus (newer format) + if (f.available() > 0) { + uint8_t statusByte = 0; + if (f.readBytes((char *)&statusByte, 1) == 1) { + m.ackStatus = static_cast(statusByte); + } else { + m.ackStatus = AckStatus::UNKNOWN; // fallback + } + } else { + m.ackStatus = AckStatus::UNKNOWN; // legacy files + } + // Recompute type from dest if (m.dest == NODENUM_BROADCAST) { m.type = MessageType::BROADCAST; diff --git a/src/MessageStore.h b/src/MessageStore.h index 5f991d850..d1edc067a 100644 --- a/src/MessageStore.h +++ b/src/MessageStore.h @@ -11,6 +11,14 @@ constexpr size_t MAX_MESSAGE_SIZE = 220; // safe bound for text payload // Explicit message classification enum class MessageType : uint8_t { BROADCAST = 0, DM_TO_US = 1 }; +// Delivery status for messages we sent +enum class AckStatus : uint8_t { + UNKNOWN = 0, // default (not yet resolved) + ACKED = 1, // got a valid ACK + NACKED = 2, // explicitly failed + TIMEOUT = 3 // no ACK after retry window +}; + struct StoredMessage { uint32_t timestamp; // When message was created (secs since boot/RTC) uint32_t sender; // NodeNum of sender @@ -29,10 +37,13 @@ struct StoredMessage { // (true = millis()/1000 fallback, false = epoch/RTC absolute) bool isBootRelative; + // Delivery status (only meaningful for our own sent messages) + AckStatus ackStatus; + // Default constructor to initialize all fields safely StoredMessage() : timestamp(0), sender(0), channelIndex(0), text(""), dest(0xffffffff), type(MessageType::BROADCAST), - isBootRelative(false) + isBootRelative(false), ackStatus(AckStatus::UNKNOWN) { } }; diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index f8d232b72..4d804f195 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -233,6 +233,33 @@ const std::vector &getSeenPeers() return seenPeers; } +// Helpers for drawing status marks +void drawCheckMark(OLEDDisplay *display, int x, int y, int size = 8) +{ + int h = size; + int w = size; + + // Center mark vertically with the text row + int midY = y + (FONT_HEIGHT_SMALL / 2); + int topY = midY - (h / 2); + + display->drawLine(x, topY + h / 2, x + w / 3, topY + h); + display->drawLine(x + w / 3, topY + h, x + w, topY); +} + +void drawXMark(OLEDDisplay *display, int x, int y, int size = 8) +{ + int h = size; + int w = size; + + // Center mark vertically with the text row + int midY = y + (FONT_HEIGHT_SMALL / 2); + int topY = midY - (h / 2); + + display->drawLine(x, topY, x + w, topY + h); + display->drawLine(x + w, topY, x, topY + h); +} + void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { // Ensure any boot-relative timestamps are upgraded if RTC is valid @@ -329,6 +356,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 std::vector allLines; std::vector isMine; // track alignment std::vector isHeader; // track header lines + std::vector ackForLine; for (auto it = filtered.rbegin(); it != filtered.rend(); ++it) { const auto &m = *it; @@ -417,6 +445,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 allLines.push_back(std::string(headerStr)); isMine.push_back(mine); isHeader.push_back(true); + ackForLine.push_back(m.ackStatus); // Split message text into wrapped lines std::vector wrapped = generateLines(display, "", m.text.c_str(), textWidth); @@ -424,6 +453,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 allLines.push_back(ln); isMine.push_back(mine); isHeader.push_back(false); + ackForLine.push_back(AckStatus::UNKNOWN); } } @@ -484,6 +514,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 int headerX = isMine[i] ? (SCREEN_WIDTH - w - 2) : x; display->drawString(headerX, lineY, cachedLines[i].c_str()); + // Draw ACK/NACK mark for our own messages + if (isMine[i]) { + int markX = headerX - 10; + int markY = lineY; + if (ackForLine[i] == AckStatus::ACKED) { + drawCheckMark(display, markX, markY, 8); + } else if (ackForLine[i] == AckStatus::NACKED || ackForLine[i] == AckStatus::TIMEOUT) { + drawXMark(display, markX, markY, 8); + } + } + // Draw underline just under header text int underlineY = lineY + FONT_HEIGHT_SMALL; for (int px = 0; px < w; ++px) { diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 5c36f8328..de6732f3f 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -1008,6 +1008,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha sm.dest = dest; sm.type = MessageType::DM_TO_US; } + sm.ackStatus = AckStatus::UNKNOWN; messageStore.addLiveMessage(sm); @@ -2185,6 +2186,20 @@ ProcessMessage CannedMessageModule::handleReceived(const meshtastic_MeshPacket & waitingForAck = false; + // Update last sent StoredMessage with ACK/NACK result + if (!messageStore.getMessages().empty()) { + StoredMessage &last = const_cast(messageStore.getMessages().back()); + if (last.sender == nodeDB->getNodeNum()) { // only update our own messages + if (this->ack) { + last.ackStatus = AckStatus::ACKED; + } else { + // If error_reason was explicit, you can map to NACKED; otherwise TIMEOUT + last.ackStatus = + (decoded.error_reason == meshtastic_Routing_Error_NONE) ? AckStatus::ACKED : AckStatus::NACKED; + } + } + } + // Capture radio metrics from this ACK/NACK packet this->lastRxRssi = mp.rx_rssi; this->lastRxSnr = mp.rx_snr;