mirror of
https://github.com/meshtastic/firmware.git
synced 2025-10-28 07:13:25 +00:00
First try at multimessage storage and display
This commit is contained in:
parent
040b3b8c7f
commit
cf9bc7ac00
214
src/MessageStore.cpp
Normal file
214
src/MessageStore.cpp
Normal file
@ -0,0 +1,214 @@
|
||||
#include "MessageStore.h"
|
||||
#include "FSCommon.h"
|
||||
#include "NodeDB.h" // for nodeDB->getNodeNum()
|
||||
#include "SPILock.h"
|
||||
#include "SafeFile.h"
|
||||
#include "configuration.h" // for millis()
|
||||
|
||||
MessageStore::MessageStore(const std::string &label)
|
||||
{
|
||||
filename = "/Messages_" + label + ".msgs";
|
||||
}
|
||||
|
||||
// === Live message handling (RAM only) ===
|
||||
void MessageStore::addLiveMessage(const StoredMessage &msg)
|
||||
{
|
||||
if (liveMessages.size() >= MAX_MESSAGES_SAVED) {
|
||||
liveMessages.pop_front(); // keep only most recent N
|
||||
}
|
||||
liveMessages.push_back(msg);
|
||||
}
|
||||
|
||||
// === Persistence queue (used only on shutdown/reboot) ===
|
||||
void MessageStore::addMessage(const StoredMessage &msg)
|
||||
{
|
||||
if (messages.size() >= MAX_MESSAGES_SAVED) {
|
||||
messages.pop_front();
|
||||
}
|
||||
messages.push_back(msg);
|
||||
}
|
||||
|
||||
void MessageStore::addFromPacket(const meshtastic_MeshPacket &packet)
|
||||
{
|
||||
StoredMessage sm;
|
||||
|
||||
sm.timestamp = packet.rx_time ? packet.rx_time : (millis() / 1000);
|
||||
sm.sender = packet.from;
|
||||
sm.channelIndex = packet.channel;
|
||||
sm.text = std::string(reinterpret_cast<const char *>(packet.decoded.payload.bytes));
|
||||
|
||||
// Classification logic
|
||||
if (packet.to == NODENUM_BROADCAST || packet.decoded.dest == NODENUM_BROADCAST) {
|
||||
sm.dest = NODENUM_BROADCAST;
|
||||
sm.type = MessageType::BROADCAST;
|
||||
} else if (packet.to == nodeDB->getNodeNum()) {
|
||||
sm.dest = nodeDB->getNodeNum(); // DM to us
|
||||
sm.type = MessageType::DM_TO_US;
|
||||
} else {
|
||||
sm.dest = NODENUM_BROADCAST; // fallback
|
||||
sm.type = MessageType::BROADCAST;
|
||||
}
|
||||
|
||||
addLiveMessage(sm);
|
||||
}
|
||||
|
||||
// === Outgoing/manual message ===
|
||||
void MessageStore::addFromString(uint32_t sender, uint8_t channelIndex, const std::string &text)
|
||||
{
|
||||
StoredMessage sm;
|
||||
sm.timestamp = millis() / 1000;
|
||||
sm.sender = sender;
|
||||
sm.channelIndex = channelIndex;
|
||||
sm.text = text;
|
||||
|
||||
// Default manual adds to broadcast
|
||||
sm.dest = NODENUM_BROADCAST;
|
||||
sm.type = MessageType::BROADCAST;
|
||||
|
||||
addLiveMessage(sm);
|
||||
}
|
||||
|
||||
// === Save RAM queue to flash (called on shutdown) ===
|
||||
void MessageStore::saveToFlash()
|
||||
{
|
||||
#ifdef FSCom
|
||||
// Copy live RAM buffer into persistence queue
|
||||
messages = liveMessages;
|
||||
|
||||
spiLock->lock();
|
||||
FSCom.mkdir("/"); // ensure root exists
|
||||
spiLock->unlock();
|
||||
|
||||
SafeFile f(filename.c_str(), false);
|
||||
|
||||
spiLock->lock();
|
||||
uint8_t count = messages.size();
|
||||
f.write(&count, 1);
|
||||
|
||||
for (uint8_t i = 0; i < messages.size() && i < MAX_MESSAGES_SAVED; i++) {
|
||||
const StoredMessage &m = messages.at(i);
|
||||
f.write((uint8_t *)&m.timestamp, sizeof(m.timestamp));
|
||||
f.write((uint8_t *)&m.sender, sizeof(m.sender));
|
||||
f.write((uint8_t *)&m.channelIndex, sizeof(m.channelIndex));
|
||||
f.write((uint8_t *)&m.dest, sizeof(m.dest));
|
||||
f.write((uint8_t *)m.text.c_str(), std::min(MAX_MESSAGE_SIZE, (uint32_t)m.text.size()));
|
||||
f.write('\0'); // null terminator
|
||||
}
|
||||
spiLock->unlock();
|
||||
|
||||
f.close();
|
||||
#else
|
||||
// Filesystem not available, skip persistence
|
||||
#endif
|
||||
}
|
||||
|
||||
// === Load persisted messages into RAM (called at boot) ===
|
||||
void MessageStore::loadFromFlash()
|
||||
{
|
||||
messages.clear();
|
||||
liveMessages.clear();
|
||||
#ifdef FSCom
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
|
||||
if (!FSCom.exists(filename.c_str()))
|
||||
return;
|
||||
auto f = FSCom.open(filename.c_str(), FILE_O_READ);
|
||||
if (!f)
|
||||
return;
|
||||
|
||||
uint8_t count = 0;
|
||||
f.readBytes((char *)&count, 1);
|
||||
|
||||
for (uint8_t i = 0; i < count && i < MAX_MESSAGES_SAVED; i++) {
|
||||
StoredMessage m;
|
||||
f.readBytes((char *)&m.timestamp, sizeof(m.timestamp));
|
||||
f.readBytes((char *)&m.sender, sizeof(m.sender));
|
||||
f.readBytes((char *)&m.channelIndex, sizeof(m.channelIndex));
|
||||
f.readBytes((char *)&m.dest, sizeof(m.dest));
|
||||
|
||||
char c;
|
||||
while (m.text.size() < MAX_MESSAGE_SIZE) {
|
||||
if (f.readBytes(&c, 1) <= 0)
|
||||
break;
|
||||
if (c == '\0')
|
||||
break;
|
||||
m.text.push_back(c);
|
||||
}
|
||||
|
||||
// Recompute type from dest
|
||||
if (m.dest == NODENUM_BROADCAST) {
|
||||
m.type = MessageType::BROADCAST;
|
||||
} else {
|
||||
m.type = MessageType::DM_TO_US;
|
||||
}
|
||||
|
||||
messages.push_back(m);
|
||||
liveMessages.push_back(m); // restore into RAM buffer
|
||||
}
|
||||
f.close();
|
||||
#endif
|
||||
}
|
||||
|
||||
// === Clear all messages (RAM + persisted queue) ===
|
||||
void MessageStore::clearAllMessages()
|
||||
{
|
||||
liveMessages.clear();
|
||||
messages.clear();
|
||||
|
||||
#ifdef FSCom
|
||||
SafeFile f(filename.c_str(), false);
|
||||
uint8_t count = 0;
|
||||
f.write(&count, 1); // write "0 messages"
|
||||
f.close();
|
||||
#endif
|
||||
}
|
||||
|
||||
// === Dismiss oldest message (RAM + persisted queue) ===
|
||||
void MessageStore::dismissOldestMessage()
|
||||
{
|
||||
if (!liveMessages.empty()) {
|
||||
liveMessages.pop_front();
|
||||
}
|
||||
if (!messages.empty()) {
|
||||
messages.pop_front();
|
||||
}
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
// === Dismiss newest message (RAM + persisted queue) ===
|
||||
void MessageStore::dismissNewestMessage()
|
||||
{
|
||||
if (!liveMessages.empty()) {
|
||||
liveMessages.pop_back();
|
||||
}
|
||||
if (!messages.empty()) {
|
||||
messages.pop_back();
|
||||
}
|
||||
saveToFlash();
|
||||
}
|
||||
|
||||
// === Helper filters for future use ===
|
||||
std::deque<StoredMessage> MessageStore::getChannelMessages(uint8_t channel) const
|
||||
{
|
||||
std::deque<StoredMessage> result;
|
||||
for (const auto &m : liveMessages) {
|
||||
if (m.type == MessageType::BROADCAST && m.channelIndex == channel) {
|
||||
result.push_back(m);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::deque<StoredMessage> MessageStore::getDirectMessages() const
|
||||
{
|
||||
std::deque<StoredMessage> result;
|
||||
for (const auto &m : liveMessages) {
|
||||
if (m.type == MessageType::DM_TO_US) {
|
||||
result.push_back(m);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// === Global definition ===
|
||||
MessageStore messageStore("default");
|
||||
75
src/MessageStore.h
Normal file
75
src/MessageStore.h
Normal file
@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
#include "mesh/generated/meshtastic/mesh.pb.h" // for meshtastic_MeshPacket
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
|
||||
// Max number of messages we’ll keep in history
|
||||
constexpr size_t MAX_MESSAGES_SAVED = 20;
|
||||
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 };
|
||||
|
||||
struct StoredMessage {
|
||||
uint32_t timestamp; // When message was created (secs since boot/RTC)
|
||||
uint32_t sender; // NodeNum of sender
|
||||
uint8_t channelIndex; // Channel index used
|
||||
std::string text; // UTF-8 text payload
|
||||
|
||||
// Destination node.
|
||||
// 0xffffffff (NODENUM_BROADCAST) means broadcast,
|
||||
// otherwise this is the NodeNum of the DM recipient.
|
||||
uint32_t dest;
|
||||
|
||||
// Explicit classification (derived from dest when loading old messages)
|
||||
MessageType type;
|
||||
};
|
||||
|
||||
class MessageStore
|
||||
{
|
||||
public:
|
||||
explicit MessageStore(const std::string &label);
|
||||
|
||||
// === Live RAM methods (always current, used by UI and runtime) ===
|
||||
void addLiveMessage(const StoredMessage &msg);
|
||||
const std::deque<StoredMessage> &getLiveMessages() const { return liveMessages; }
|
||||
|
||||
// === Persistence methods (used only on boot/shutdown) ===
|
||||
void addMessage(const StoredMessage &msg); // Add to persistence queue
|
||||
void addFromPacket(const meshtastic_MeshPacket &mp); // Incoming → RAM only
|
||||
void addFromString(uint32_t sender, uint8_t channelIndex,
|
||||
const std::string &text); // Outgoing/manual → RAM only
|
||||
void saveToFlash(); // Persist RAM → flash
|
||||
void loadFromFlash(); // Restore flash → RAM
|
||||
|
||||
// === Clear all messages (RAM + persisted queue) ===
|
||||
void clearAllMessages();
|
||||
|
||||
// === Dismiss helpers ===
|
||||
void dismissOldestMessage(); // Drop front() from history
|
||||
void dismissNewestMessage(); // Drop back() from history (useful for current screen)
|
||||
|
||||
// === Unified accessor (for UI code, defaults to RAM buffer) ===
|
||||
const std::deque<StoredMessage> &getMessages() const { return liveMessages; }
|
||||
|
||||
// Optional: direct access to persisted copy (mainly for debugging/inspection)
|
||||
const std::deque<StoredMessage> &getPersistedMessages() const { return messages; }
|
||||
|
||||
// === Helper filters for future use ===
|
||||
std::deque<StoredMessage> getChannelMessages(uint8_t channel) const;
|
||||
std::deque<StoredMessage> getDirectMessages() const;
|
||||
std::deque<StoredMessage> getConversationWith(uint32_t peer) const;
|
||||
|
||||
private:
|
||||
// RAM buffer (always current, main source for UI)
|
||||
std::deque<StoredMessage> liveMessages;
|
||||
|
||||
// Persisted storage (only updated on shutdown, loaded at boot)
|
||||
std::deque<StoredMessage> messages;
|
||||
|
||||
std::string filename;
|
||||
};
|
||||
|
||||
// === Global instance (defined in MessageStore.cpp) ===
|
||||
extern MessageStore messageStore;
|
||||
@ -11,6 +11,7 @@
|
||||
* For more information, see: https://meshtastic.org/
|
||||
*/
|
||||
#include "power.h"
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "Throttle.h"
|
||||
@ -757,6 +758,8 @@ void Power::shutdown()
|
||||
playShutdownMelody();
|
||||
#endif
|
||||
nodeDB->saveToDisk();
|
||||
// === Save live messages before powering off ===
|
||||
messageStore.saveToFlash();
|
||||
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
#ifdef PIN_LED1
|
||||
|
||||
@ -46,6 +46,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#endif
|
||||
#include "FSCommon.h"
|
||||
#include "MeshService.h"
|
||||
#include "MessageStore.h"
|
||||
#include "RadioLibInterface.h"
|
||||
#include "error.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
@ -64,6 +65,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "modules/WaypointModule.h"
|
||||
#include "sleep.h"
|
||||
#include "target_specific.h"
|
||||
extern MessageStore messageStore;
|
||||
|
||||
using graphics::Emote;
|
||||
using graphics::emotes;
|
||||
@ -651,6 +653,10 @@ void Screen::setup()
|
||||
if (inputBroker)
|
||||
inputObserver.observe(inputBroker);
|
||||
|
||||
// === Load persisted messages into RAM ===
|
||||
messageStore.loadFromFlash();
|
||||
LOG_INFO("MessageStore loaded from flash");
|
||||
|
||||
// === Notify modules that support UI events ===
|
||||
MeshModule::observeUIEvents(&uiFrameEventObserver);
|
||||
}
|
||||
@ -1414,21 +1420,41 @@ int Screen::handleStatusUpdate(const meshtastic::Status *arg)
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handles when message is received; will jump to text message frame.
|
||||
int Screen::handleTextMessage(const meshtastic_MeshPacket *packet)
|
||||
{
|
||||
if (showingNormalScreen) {
|
||||
if (packet->from == 0) {
|
||||
// Outgoing message (likely sent from phone)
|
||||
// === Outgoing message (likely sent from phone) ===
|
||||
devicestate.has_rx_text_message = false;
|
||||
memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message));
|
||||
hiddenFrames.textMessage = true;
|
||||
hasUnreadMessage = false; // Clear unread state when user replies
|
||||
|
||||
setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list
|
||||
|
||||
// === Save our own outgoing message to live RAM ===
|
||||
StoredMessage sm;
|
||||
sm.timestamp = millis() / 1000;
|
||||
sm.sender = nodeDB->getNodeNum(); // us
|
||||
sm.channelIndex = packet->channel;
|
||||
sm.text = std::string(reinterpret_cast<const char *>(packet->decoded.payload.bytes));
|
||||
|
||||
// ✅ Distinguish between broadcast vs DM to us
|
||||
if (packet->decoded.dest == NODENUM_BROADCAST) {
|
||||
sm.dest = NODENUM_BROADCAST;
|
||||
sm.type = MessageType::BROADCAST;
|
||||
} else {
|
||||
sm.dest = nodeDB->getNodeNum();
|
||||
sm.type = MessageType::DM_TO_US;
|
||||
}
|
||||
|
||||
messageStore.addLiveMessage(sm); // RAM only (flash updated at shutdown)
|
||||
|
||||
// 🔹 Reset scroll so newest message starts from the top
|
||||
graphics::MessageRenderer::resetScrollState();
|
||||
} else {
|
||||
// Incoming message
|
||||
// === Incoming message ===
|
||||
devicestate.has_rx_text_message = true; // Needed to include the message frame
|
||||
hasUnreadMessage = true; // Enables mail icon in the header
|
||||
setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view
|
||||
@ -1438,12 +1464,12 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet)
|
||||
setOn(true); // Wake up the screen first
|
||||
forceDisplay(); // Forces screen redraw
|
||||
}
|
||||
|
||||
// === Prepare banner content ===
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from);
|
||||
const char *longName = (node && node->has_user) ? node->user.long_name : nullptr;
|
||||
|
||||
const char *msgRaw = reinterpret_cast<const char *>(packet->decoded.payload.bytes);
|
||||
|
||||
char banner[256];
|
||||
|
||||
// Check for bell character in message to determine alert type
|
||||
@ -1468,11 +1494,11 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet)
|
||||
#else
|
||||
snprintf(banner, sizeof(banner), "New Message from\n%s", longName);
|
||||
#endif
|
||||
|
||||
} else {
|
||||
strcpy(banner, "New Message");
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(M5STACK_UNITC6L)
|
||||
screen->setOn(true);
|
||||
screen->showSimpleBanner(banner, 1500);
|
||||
@ -1480,6 +1506,27 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet)
|
||||
#else
|
||||
screen->showSimpleBanner(banner, 3000);
|
||||
#endif
|
||||
|
||||
// === Save this incoming message to live RAM ===
|
||||
StoredMessage sm;
|
||||
sm.timestamp = packet->rx_time ? packet->rx_time : (millis() / 1000);
|
||||
sm.sender = packet->from;
|
||||
sm.channelIndex = packet->channel;
|
||||
sm.text = std::string(reinterpret_cast<const char *>(packet->decoded.payload.bytes));
|
||||
|
||||
// ✅ Distinguish between broadcast vs DM to us
|
||||
if (packet->to == NODENUM_BROADCAST || packet->decoded.dest == NODENUM_BROADCAST) {
|
||||
sm.dest = NODENUM_BROADCAST;
|
||||
sm.type = MessageType::BROADCAST;
|
||||
} else {
|
||||
sm.dest = nodeDB->getNodeNum(); // DM to us
|
||||
sm.type = MessageType::DM_TO_US;
|
||||
}
|
||||
|
||||
messageStore.addLiveMessage(sm); // RAM only (flash updated at shutdown)
|
||||
|
||||
// 🔹 Reset scroll so newest message starts from the top
|
||||
graphics::MessageRenderer::resetScrollState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
#include "MenuHandler.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
#include "buzz.h"
|
||||
#include "graphics/Screen.h"
|
||||
@ -348,14 +349,14 @@ void menuHandler::clockMenu()
|
||||
|
||||
void menuHandler::messageResponseMenu()
|
||||
{
|
||||
enum optionsNumbers { Back = 0, Dismiss = 1, Preset = 2, Freetext = 3, Aloud = 4, enumEnd = 5 };
|
||||
enum optionsNumbers { Back = 0, DismissAll = 1, DismissOldest = 2, Preset = 3, Freetext = 4, Aloud = 5, enumEnd = 6 };
|
||||
#if defined(M5STACK_UNITC6L)
|
||||
static const char *optionsArray[enumEnd] = {"Back", "Dismiss", "Reply Preset"};
|
||||
static const char *optionsArray[enumEnd] = {"Back", "Dismiss All", "Dismiss Oldest", "Reply Preset"};
|
||||
#else
|
||||
static const char *optionsArray[enumEnd] = {"Back", "Dismiss", "Reply via Preset"};
|
||||
static const char *optionsArray[enumEnd] = {"Back", "Dismiss All", "Dismiss Oldest", "Reply via Preset"};
|
||||
#endif
|
||||
static int optionsEnumArray[enumEnd] = {Back, Dismiss, Preset};
|
||||
int options = 3;
|
||||
static int optionsEnumArray[enumEnd] = {Back, DismissAll, DismissOldest, Preset};
|
||||
int options = 4;
|
||||
|
||||
if (kb_found) {
|
||||
optionsArray[options] = "Reply via Freetext";
|
||||
@ -376,8 +377,12 @@ void menuHandler::messageResponseMenu()
|
||||
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
||||
bannerOptions.optionsCount = options;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
if (selected == Dismiss) {
|
||||
screen->hideCurrentFrame();
|
||||
if (selected == DismissAll) {
|
||||
// Remove all messages
|
||||
messageStore.clearAllMessages();
|
||||
} else if (selected == DismissOldest) {
|
||||
// Remove only the oldest message
|
||||
messageStore.dismissOldestMessage();
|
||||
} else if (selected == Preset) {
|
||||
if (devicestate.rx_text_message.to == NODENUM_BROADCAST) {
|
||||
cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel);
|
||||
@ -1043,6 +1048,7 @@ void menuHandler::rebootMenu()
|
||||
if (selected == 1) {
|
||||
IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0));
|
||||
nodeDB->saveToDisk();
|
||||
messageStore.saveToFlash();
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
} else {
|
||||
menuQueue = power_menu;
|
||||
|
||||
@ -26,6 +26,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "MessageRenderer.h"
|
||||
|
||||
// Core includes
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
#include "configuration.h"
|
||||
#include "gps/RTC.h"
|
||||
@ -148,7 +149,8 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string
|
||||
|
||||
// Render the emote (if found)
|
||||
if (matchedEmote && i == nextEmotePos) {
|
||||
int iconY = fontMidline - matchedEmote->height / 2 - 1;
|
||||
// Center vertically — padding handled in calculateLineHeights
|
||||
int iconY = fontMidline - matchedEmote->height / 2;
|
||||
display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap);
|
||||
cursorX += matchedEmote->width + 1;
|
||||
i += emojiLen;
|
||||
@ -170,13 +172,39 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string
|
||||
}
|
||||
}
|
||||
|
||||
// === Scroll state (file scope so we can reset on new message) ===
|
||||
float scrollY = 0.0f;
|
||||
uint32_t lastTime = 0;
|
||||
uint32_t scrollStartDelay = 0;
|
||||
uint32_t pauseStart = 0;
|
||||
bool waitingToReset = false;
|
||||
bool scrollStarted = false;
|
||||
static bool didReset = false; // <-- add here
|
||||
|
||||
// === Reset scroll state when new messages arrive ===
|
||||
void resetScrollState()
|
||||
{
|
||||
scrollY = 0.0f;
|
||||
scrollStarted = false;
|
||||
waitingToReset = false;
|
||||
scrollStartDelay = millis();
|
||||
lastTime = millis();
|
||||
|
||||
didReset = false; // <-- now valid
|
||||
}
|
||||
|
||||
void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
if (!didReset) {
|
||||
resetScrollState();
|
||||
didReset = true;
|
||||
}
|
||||
|
||||
// Clear the unread message indicator when viewing the message
|
||||
hasUnreadMessage = false;
|
||||
|
||||
const meshtastic_MeshPacket &mp = devicestate.rx_text_message;
|
||||
const char *msg = reinterpret_cast<const char *>(mp.decoded.payload.bytes);
|
||||
// === Use live RAM buffer directly (boot handles flash load) ===
|
||||
const auto &msgs = messageStore.getMessages();
|
||||
|
||||
display->clear();
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
@ -192,20 +220,14 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
const int scrollBottom = SCREEN_HEIGHT - navHeight;
|
||||
const int usableHeight = scrollBottom;
|
||||
const int textWidth = SCREEN_WIDTH;
|
||||
|
||||
#endif
|
||||
bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
|
||||
bool isBold = config.display.heading_bold;
|
||||
|
||||
// === Set Title
|
||||
const char *titleStr = "Messages";
|
||||
|
||||
// Check if we have more than an empty message to show
|
||||
char messageBuf[237];
|
||||
snprintf(messageBuf, sizeof(messageBuf), "%s", msg);
|
||||
if (strlen(messageBuf) == 0) {
|
||||
// === Header ===
|
||||
if (msgs.empty()) {
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
didReset = false;
|
||||
const char *messageString = "No messages";
|
||||
int center_text = (SCREEN_WIDTH / 2) - (display->getStringWidth(messageString) / 2);
|
||||
#if defined(M5STACK_UNITC6L)
|
||||
@ -216,176 +238,103 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
return;
|
||||
}
|
||||
|
||||
// === Header Construction ===
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp));
|
||||
char headerStr[80];
|
||||
const char *sender = "???";
|
||||
// === Build lines for all messages (newest first) ===
|
||||
std::vector<std::string> allLines;
|
||||
std::vector<bool> isMine; // track alignment
|
||||
std::vector<bool> isHeader; // track header lines
|
||||
|
||||
for (auto it = msgs.rbegin(); it != msgs.rend(); ++it) {
|
||||
const auto &m = *it;
|
||||
|
||||
// --- Build header line for this message ---
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender);
|
||||
const char *sender = "???";
|
||||
#if defined(M5STACK_UNITC6L)
|
||||
if (node && node->has_user)
|
||||
sender = node->user.short_name;
|
||||
#else
|
||||
if (node && node->has_user) {
|
||||
if (SCREEN_WIDTH >= 200 && strlen(node->user.long_name) > 0) {
|
||||
sender = node->user.long_name;
|
||||
} else {
|
||||
if (node && node->has_user)
|
||||
sender = node->user.short_name;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
uint32_t seconds = sinceReceived(&mp), minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
|
||||
uint8_t timestampHours, timestampMinutes;
|
||||
int32_t daysAgo;
|
||||
bool useTimestamp = deltaToTimestamp(seconds, ×tampHours, ×tampMinutes, &daysAgo);
|
||||
|
||||
if (useTimestamp && minutes >= 15 && daysAgo == 0) {
|
||||
std::string prefix = (daysAgo == 1 && SCREEN_WIDTH >= 200) ? "Yesterday" : "At";
|
||||
if (config.display.use_12h_clock) {
|
||||
bool isPM = timestampHours >= 12;
|
||||
timestampHours = timestampHours % 12;
|
||||
if (timestampHours == 0)
|
||||
timestampHours = 12;
|
||||
snprintf(headerStr, sizeof(headerStr), "%s %d:%02d%s from %s", prefix.c_str(), timestampHours, timestampMinutes,
|
||||
isPM ? "p" : "a", sender);
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s %d:%02d from %s", prefix.c_str(), timestampHours, timestampMinutes,
|
||||
sender);
|
||||
}
|
||||
} else {
|
||||
#if defined(M5STACK_UNITC6L)
|
||||
snprintf(headerStr, sizeof(headerStr), "%s from %s", UIRenderer::drawTimeDelta(days, hours, minutes, seconds).c_str(),
|
||||
sender);
|
||||
#else
|
||||
snprintf(headerStr, sizeof(headerStr), "%s ago from %s", UIRenderer::drawTimeDelta(days, hours, minutes, seconds).c_str(),
|
||||
sender);
|
||||
#endif
|
||||
}
|
||||
#if defined(M5STACK_UNITC6L)
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
int headerY = getTextPositions(display)[1];
|
||||
display->drawString(x, headerY, headerStr);
|
||||
for (int separatorX = 0; separatorX < SCREEN_WIDTH; separatorX += 2) {
|
||||
display->setPixel(separatorX, fixedTopHeight - 1);
|
||||
}
|
||||
cachedLines.clear();
|
||||
std::string fullMsg(messageBuf);
|
||||
std::string currentLine;
|
||||
for (size_t i = 0; i < fullMsg.size();) {
|
||||
unsigned char c = fullMsg[i];
|
||||
size_t charLen = 1;
|
||||
if ((c & 0xE0) == 0xC0)
|
||||
charLen = 2;
|
||||
else if ((c & 0xF0) == 0xE0)
|
||||
charLen = 3;
|
||||
else if ((c & 0xF8) == 0xF0)
|
||||
charLen = 4;
|
||||
std::string nextChar = fullMsg.substr(i, charLen);
|
||||
std::string testLine = currentLine + nextChar;
|
||||
if (display->getStringWidth(testLine.c_str()) > windowWidth) {
|
||||
cachedLines.push_back(currentLine);
|
||||
currentLine = nextChar;
|
||||
} else {
|
||||
currentLine = testLine;
|
||||
}
|
||||
|
||||
i += charLen;
|
||||
}
|
||||
if (!currentLine.empty())
|
||||
cachedLines.push_back(currentLine);
|
||||
cachedHeights = calculateLineHeights(cachedLines, emotes);
|
||||
int yOffset = windowY;
|
||||
int linesDrawn = 0;
|
||||
for (size_t i = 0; i < cachedLines.size(); ++i) {
|
||||
if (linesDrawn >= 2)
|
||||
break;
|
||||
int lineHeight = cachedHeights[i];
|
||||
if (yOffset + lineHeight > windowY + windowHeight)
|
||||
break;
|
||||
drawStringWithEmotes(display, windowX, yOffset, cachedLines[i], emotes, numEmotes);
|
||||
yOffset += lineHeight;
|
||||
linesDrawn++;
|
||||
}
|
||||
screen->forceDisplay();
|
||||
#else
|
||||
uint32_t now = millis();
|
||||
#ifndef EXCLUDE_EMOJI
|
||||
// === Bounce animation setup ===
|
||||
static uint32_t lastBounceTime = 0;
|
||||
static int bounceY = 0;
|
||||
const int bounceRange = 2; // Max pixels to bounce up/down
|
||||
const int bounceInterval = 10; // How quickly to change bounce direction (ms)
|
||||
|
||||
if (now - lastBounceTime >= bounceInterval) {
|
||||
lastBounceTime = now;
|
||||
bounceY = (bounceY + 1) % (bounceRange * 2);
|
||||
}
|
||||
for (int i = 0; i < numEmotes; ++i) {
|
||||
const Emote &e = emotes[i];
|
||||
if (strcmp(msg, e.label) == 0) {
|
||||
int headerY = getTextPositions(display)[1]; // same as scrolling header line
|
||||
display->drawString(x + 3, headerY, headerStr);
|
||||
if (isInverted && isBold)
|
||||
display->drawString(x + 4, headerY, headerStr);
|
||||
|
||||
// Draw separator (same as scroll version)
|
||||
for (int separatorX = 1; separatorX <= (display->getStringWidth(headerStr) + 2); separatorX += 2) {
|
||||
display->setPixel(separatorX, headerY + ((isHighResolution) ? 19 : 13));
|
||||
if (node && node->has_user) {
|
||||
if (SCREEN_WIDTH >= 200 && strlen(node->user.long_name) > 0) {
|
||||
sender = node->user.long_name;
|
||||
} else {
|
||||
sender = node->user.short_name;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Center the emote below the header line + separator + nav
|
||||
int remainingHeight = SCREEN_HEIGHT - (headerY + FONT_HEIGHT_SMALL) - navHeight;
|
||||
int emoteY = headerY + 6 + FONT_HEIGHT_SMALL + (remainingHeight - e.height) / 2 + bounceY - bounceRange;
|
||||
display->drawXbm((SCREEN_WIDTH - e.width) / 2, emoteY, e.width, e.height, e.bitmap);
|
||||
// If this is *our own* message, override sender to "Me"
|
||||
bool mine = (m.sender == nodeDB->getNodeNum());
|
||||
if (mine) {
|
||||
sender = "Me";
|
||||
}
|
||||
|
||||
// Draw header at the end to sort out overlapping elements
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
return;
|
||||
// === Channel / destination labeling ===
|
||||
char chanType[32];
|
||||
if (m.dest == NODENUM_BROADCAST) {
|
||||
// Broadcast to a channel
|
||||
snprintf(chanType, sizeof(chanType), "(Ch%d)", m.channelIndex + 1);
|
||||
} else {
|
||||
// Direct message (always to us if it shows up)
|
||||
snprintf(chanType, sizeof(chanType), "(DM)");
|
||||
}
|
||||
|
||||
// === Calculate how long ago ===
|
||||
uint32_t nowSecs = millis() / 1000;
|
||||
uint32_t seconds = (nowSecs > m.timestamp) ? (nowSecs - m.timestamp) : 0;
|
||||
|
||||
// Fallback if timestamp looks bogus (0 or way too large)
|
||||
bool invalidTime = (m.timestamp == 0 || seconds > 315360000); // >10 years
|
||||
|
||||
char timeBuf[16];
|
||||
if (invalidTime) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "???");
|
||||
} else if (seconds < 60) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%us ago", seconds);
|
||||
} else if (seconds < 3600) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%um ago", seconds / 60);
|
||||
} else if (seconds < 86400) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%uh ago", seconds / 3600);
|
||||
} else {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%ud ago", seconds / 86400);
|
||||
}
|
||||
|
||||
char headerStr[96];
|
||||
if (mine) {
|
||||
snprintf(headerStr, sizeof(headerStr), "me %s %s", timeBuf, chanType);
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s from %s %s", timeBuf, sender, chanType);
|
||||
}
|
||||
|
||||
// Push header line
|
||||
allLines.push_back(std::string(headerStr));
|
||||
isMine.push_back(mine);
|
||||
isHeader.push_back(true);
|
||||
|
||||
// --- Split message text into wrapped lines ---
|
||||
std::vector<std::string> wrapped = generateLines(display, "", m.text.c_str(), textWidth);
|
||||
for (auto &ln : wrapped) {
|
||||
allLines.push_back(ln);
|
||||
isMine.push_back(mine);
|
||||
isHeader.push_back(false);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// === Generate the cache key ===
|
||||
size_t currentKey = (size_t)mp.from;
|
||||
currentKey ^= ((size_t)mp.to << 8);
|
||||
currentKey ^= ((size_t)mp.rx_time << 16);
|
||||
currentKey ^= ((size_t)mp.id << 24);
|
||||
|
||||
if (cachedKey != currentKey) {
|
||||
LOG_INFO("Onscreen message scroll cache key needs updating: cachedKey=0x%0x, currentKey=0x%x", cachedKey, currentKey);
|
||||
// === Cache lines and heights ===
|
||||
cachedLines = allLines;
|
||||
cachedHeights = calculateLineHeights(cachedLines, emotes);
|
||||
|
||||
// Cache miss - regenerate lines and heights
|
||||
cachedLines = generateLines(display, headerStr, messageBuf, textWidth);
|
||||
cachedHeights = calculateLineHeights(cachedLines, emotes);
|
||||
cachedKey = currentKey;
|
||||
} else {
|
||||
// Cache hit but update the header line with current time information
|
||||
cachedLines[0] = std::string(headerStr);
|
||||
// The header always has a fixed height since it doesn't contain emotes
|
||||
// As per calculateLineHeights logic for lines without emotes:
|
||||
cachedHeights[0] = FONT_HEIGHT_SMALL - 2;
|
||||
if (cachedHeights[0] < 8)
|
||||
cachedHeights[0] = 8; // minimum safety
|
||||
}
|
||||
|
||||
// === Scrolling logic ===
|
||||
// === Scrolling logic (unchanged) ===
|
||||
uint32_t now = millis();
|
||||
int totalHeight = 0;
|
||||
for (size_t i = 1; i < cachedHeights.size(); ++i) {
|
||||
for (size_t i = 0; i < cachedHeights.size(); ++i)
|
||||
totalHeight += cachedHeights[i];
|
||||
}
|
||||
int usableScrollHeight = usableHeight - cachedHeights[0]; // remove header height
|
||||
int usableScrollHeight = usableHeight;
|
||||
int scrollStop = std::max(0, totalHeight - usableScrollHeight + cachedHeights.back());
|
||||
|
||||
static float scrollY = 0.0f;
|
||||
static uint32_t lastTime = 0, scrollStartDelay = 0, pauseStart = 0;
|
||||
static bool waitingToReset = false, scrollStarted = false;
|
||||
|
||||
// === Smooth scrolling adjustment ===
|
||||
// You can tweak this divisor to change how smooth it scrolls.
|
||||
// Lower = smoother, but can feel slow.
|
||||
float delta = (now - lastTime) / 400.0f;
|
||||
lastTime = now;
|
||||
const float scrollSpeed = 2.0f;
|
||||
|
||||
const float scrollSpeed = 2.0f; // pixels per second
|
||||
|
||||
// Delay scrolling start by 2 seconds
|
||||
if (scrollStartDelay == 0)
|
||||
scrollStartDelay = now;
|
||||
if (!scrollStarted && now - scrollStartDelay > 2000)
|
||||
@ -413,22 +362,49 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
|
||||
int scrollOffset = static_cast<int>(scrollY);
|
||||
int yOffset = -scrollOffset + getTextPositions(display)[1];
|
||||
for (int separatorX = 1; separatorX <= (display->getStringWidth(headerStr) + 2); separatorX += 2) {
|
||||
display->setPixel(separatorX, yOffset + ((isHighResolution) ? 19 : 13));
|
||||
}
|
||||
|
||||
// === Render visible lines ===
|
||||
renderMessageContent(display, cachedLines, cachedHeights, x, yOffset, scrollBottom, emotes, numEmotes, isInverted, isBold);
|
||||
for (size_t i = 0; i < cachedLines.size(); ++i) {
|
||||
int lineY = yOffset;
|
||||
for (size_t j = 0; j < i; ++j)
|
||||
lineY += cachedHeights[j];
|
||||
|
||||
// Draw header at the end to sort out overlapping elements
|
||||
if (lineY > -cachedHeights[i] && lineY < scrollBottom) {
|
||||
if (isHeader[i]) {
|
||||
// Render header
|
||||
int w = display->getStringWidth(cachedLines[i].c_str());
|
||||
int headerX = isMine[i] ? (SCREEN_WIDTH - w - 2) : x;
|
||||
display->drawString(headerX, lineY, cachedLines[i].c_str());
|
||||
|
||||
// Draw underline just under header text
|
||||
int underlineY = lineY + FONT_HEIGHT_SMALL;
|
||||
for (int px = 0; px < w; ++px) {
|
||||
display->setPixel(headerX + px, underlineY);
|
||||
}
|
||||
} else {
|
||||
// Render message line
|
||||
if (isMine[i]) {
|
||||
int w = display->getStringWidth(cachedLines[i].c_str());
|
||||
int rightX = SCREEN_WIDTH - w - 2;
|
||||
drawStringWithEmotes(display, rightX, lineY, cachedLines[i], emotes, numEmotes);
|
||||
} else {
|
||||
drawStringWithEmotes(display, x, lineY, cachedLines[i], emotes, numEmotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Draw screen title header last
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::vector<std::string> generateLines(OLEDDisplay *display, const char *headerStr, const char *messageBuf, int textWidth)
|
||||
{
|
||||
std::vector<std::string> lines;
|
||||
lines.push_back(std::string(headerStr)); // Header line is always first
|
||||
|
||||
// Only push headerStr if it's not empty (prevents extra blank line after headers)
|
||||
if (headerStr && headerStr[0] != '\0') {
|
||||
lines.push_back(std::string(headerStr));
|
||||
}
|
||||
|
||||
std::string line, word;
|
||||
for (int i = 0; messageBuf[i]; ++i) {
|
||||
@ -451,10 +427,6 @@ std::vector<std::string> generateLines(OLEDDisplay *display, const char *headerS
|
||||
} else {
|
||||
word += ch;
|
||||
std::string test = line + word;
|
||||
// Keep these lines for diagnostics
|
||||
// LOG_INFO("Char: '%c' (0x%02X)", ch, (unsigned char)ch);
|
||||
// LOG_INFO("Current String: %s", test.c_str());
|
||||
// Note: there are boolean comparison uint16 (getStringWidth) with int (textWidth), hope textWidth is always positive :)
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
uint16_t strWidth = display->getStringWidth(test.c_str(), test.length(), true);
|
||||
#else
|
||||
@ -481,10 +453,13 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
|
||||
{
|
||||
std::vector<int> rowHeights;
|
||||
|
||||
for (const auto &_line : lines) {
|
||||
for (size_t idx = 0; idx < lines.size(); ++idx) {
|
||||
const auto &_line = lines[idx];
|
||||
int lineHeight = FONT_HEIGHT_SMALL;
|
||||
bool hasEmote = false;
|
||||
bool isHeader = false;
|
||||
|
||||
// Detect emotes in this line
|
||||
for (int i = 0; i < numEmotes; ++i) {
|
||||
const Emote &e = emotes[i];
|
||||
if (_line.find(e.label) != std::string::npos) {
|
||||
@ -493,11 +468,34 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tighter spacing if no emotes on this line
|
||||
if (!hasEmote) {
|
||||
lineHeight -= 2; // reduce by 2px for tighter spacing
|
||||
// Detect header lines (start of a message, or time stamps like "5m ago")
|
||||
if (idx == 0 || _line.find("ago") != std::string::npos || _line.rfind("me ", 0) == 0) {
|
||||
isHeader = true;
|
||||
}
|
||||
|
||||
// Look ahead to see if next line is a header → this is the last line of a message
|
||||
bool beforeHeader =
|
||||
(idx + 1 < lines.size() && (lines[idx + 1].find("ago") != std::string::npos || lines[idx + 1].rfind("me ", 0) == 0));
|
||||
|
||||
if (isHeader) {
|
||||
// Headers always keep full line height
|
||||
lineHeight = FONT_HEIGHT_SMALL;
|
||||
} else if (beforeHeader) {
|
||||
if (hasEmote) {
|
||||
// Last line has emote → preserve its height + padding
|
||||
lineHeight = std::max(lineHeight, FONT_HEIGHT_SMALL) + 4;
|
||||
} else {
|
||||
// Plain last line → full spacing only
|
||||
lineHeight = FONT_HEIGHT_SMALL;
|
||||
}
|
||||
} else if (!hasEmote) {
|
||||
// Plain body line, tighter spacing
|
||||
lineHeight -= 4;
|
||||
if (lineHeight < 8)
|
||||
lineHeight = 8; // minimum safety
|
||||
lineHeight = 8; // safe minimum
|
||||
} else {
|
||||
// Line has emotes, don’t compress
|
||||
lineHeight += 4; // add breathing room
|
||||
}
|
||||
|
||||
rowHeights.push_back(lineHeight);
|
||||
|
||||
@ -26,5 +26,8 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
|
||||
void renderMessageContent(OLEDDisplay *display, const std::vector<std::string> &lines, const std::vector<int> &rowHeights, int x,
|
||||
int yOffset, int scrollBottom, const Emote *emotes, int numEmotes, bool isInverted, bool isBold);
|
||||
|
||||
// Reset scroll state when new messages arrive
|
||||
void resetScrollState();
|
||||
|
||||
} // namespace MessageRenderer
|
||||
} // namespace graphics
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
#include "Channels.h"
|
||||
#include "FSCommon.h"
|
||||
#include "MeshService.h"
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
#include "SPILock.h"
|
||||
#include "buzz.h"
|
||||
@ -20,6 +21,7 @@
|
||||
#include "mesh/generated/meshtastic/cannedmessages.pb.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/ExternalNotificationModule.h" // for buzzer control
|
||||
extern MessageStore messageStore;
|
||||
#if HAS_TRACKBALL
|
||||
#include "input/TrackballInterruptImpl1.h"
|
||||
#endif
|
||||
@ -149,7 +151,7 @@ int CannedMessageModule::splitConfiguredMessages()
|
||||
String canned_messages = cannedMessageModuleConfig.messages;
|
||||
|
||||
// Copy all message parts into the buffer
|
||||
strncpy(this->messageStore, canned_messages.c_str(), sizeof(this->messageStore));
|
||||
strncpy(this->messageBuffer, canned_messages.c_str(), sizeof(this->messageBuffer));
|
||||
|
||||
// Temporary array to allow for insertion
|
||||
const char *tempMessages[CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT + 3] = {0};
|
||||
@ -166,16 +168,16 @@ int CannedMessageModule::splitConfiguredMessages()
|
||||
#endif
|
||||
|
||||
// First message always starts at buffer start
|
||||
tempMessages[tempCount++] = this->messageStore;
|
||||
int upTo = strlen(this->messageStore) - 1;
|
||||
tempMessages[tempCount++] = this->messageBuffer;
|
||||
int upTo = strlen(this->messageBuffer) - 1;
|
||||
|
||||
// Walk buffer, splitting on '|'
|
||||
while (i < upTo) {
|
||||
if (this->messageStore[i] == '|') {
|
||||
this->messageStore[i] = '\0'; // End previous message
|
||||
if (this->messageBuffer[i] == '|') {
|
||||
this->messageBuffer[i] = '\0'; // End previous message
|
||||
if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT)
|
||||
break;
|
||||
tempMessages[tempCount++] = (this->messageStore + i + 1);
|
||||
tempMessages[tempCount++] = (this->messageBuffer + i + 1);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@ -946,49 +948,42 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
|
||||
lastDest = dest;
|
||||
lastChannel = channel;
|
||||
lastDestSet = true;
|
||||
// === Prepare packet ===
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
p->to = dest;
|
||||
p->channel = channel;
|
||||
p->want_ack = true;
|
||||
p->decoded.dest = dest; // <-- Mirror picker: NODENUM_BROADCAST or node->num
|
||||
|
||||
// Save destination for ACK/NACK UI fallback
|
||||
this->lastSentNode = dest;
|
||||
this->incoming = dest;
|
||||
|
||||
// Copy message payload
|
||||
// Copy payload
|
||||
p->decoded.payload.size = strlen(message);
|
||||
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
||||
|
||||
// Optionally add bell character
|
||||
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
p->decoded.payload.bytes[p->decoded.payload.size++] = 7; // Bell
|
||||
p->decoded.payload.bytes[p->decoded.payload.size] = '\0'; // Null-terminate
|
||||
p->decoded.payload.bytes[p->decoded.payload.size++] = 7;
|
||||
p->decoded.payload.bytes[p->decoded.payload.size] = '\0';
|
||||
}
|
||||
|
||||
// Mark as waiting for ACK to trigger ACK/NACK screen
|
||||
this->waitingForAck = true;
|
||||
|
||||
// Log outgoing message
|
||||
LOG_INFO("Send message id=%u, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes);
|
||||
|
||||
if (p->to != 0xffffffff) {
|
||||
LOG_INFO("Proactively adding %x as favorite node", p->to);
|
||||
nodeDB->set_favorite(true, p->to);
|
||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
||||
}
|
||||
|
||||
// Send to mesh and phone (even if no phone connected, to track ACKs)
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
|
||||
// === Simulate local message to clear unread UI ===
|
||||
if (screen) {
|
||||
meshtastic_MeshPacket simulatedPacket = {};
|
||||
simulatedPacket.from = 0; // Local device
|
||||
screen->handleTextMessage(&simulatedPacket);
|
||||
}
|
||||
// Save outgoing message
|
||||
StoredMessage sm;
|
||||
sm.timestamp = millis() / 1000;
|
||||
sm.sender = nodeDB->getNodeNum();
|
||||
sm.channelIndex = channel;
|
||||
sm.text = std::string(message);
|
||||
sm.dest = dest; // ✅ Will be NODENUM_BROADCAST or node->num
|
||||
|
||||
messageStore.addLiveMessage(sm);
|
||||
|
||||
playComboTune();
|
||||
}
|
||||
|
||||
int32_t CannedMessageModule::runOnce()
|
||||
{
|
||||
if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION && needsUpdate) {
|
||||
|
||||
@ -156,7 +156,7 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
String temporaryMessage;
|
||||
|
||||
// === Message Storage ===
|
||||
char messageStore[CANNED_MESSAGE_MODULE_MESSAGES_SIZE + 1];
|
||||
char messageBuffer[CANNED_MESSAGE_MODULE_MESSAGES_SIZE + 1];
|
||||
char *messages[CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT];
|
||||
int messagesCount = 0;
|
||||
int currentMessageIndex = -1;
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
#endif
|
||||
#include "GPS.h"
|
||||
#include "MeshService.h"
|
||||
#include "MessageStore.h"
|
||||
#include "Module.h"
|
||||
#include "NodeDB.h"
|
||||
#include "main.h"
|
||||
@ -77,6 +78,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event)
|
||||
case INPUT_BROKER_MSG_REBOOT:
|
||||
IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0));
|
||||
nodeDB->saveToDisk();
|
||||
messageStore.saveToFlash();
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
// runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
|
||||
return true;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user