Canned Messages via InkHUD menu (#7096)
Some checks are pending
CI / setup (check) (push) Waiting to run
CI / setup (esp32) (push) Waiting to run
CI / setup (esp32c3) (push) Waiting to run
CI / setup (esp32c6) (push) Waiting to run
CI / setup (esp32s3) (push) Waiting to run
CI / setup (nrf52840) (push) Waiting to run
CI / setup (rp2040) (push) Waiting to run
CI / setup (stm32) (push) Waiting to run
CI / check (push) Blocked by required conditions
CI / build-esp32 (push) Blocked by required conditions
CI / build-esp32-s3 (push) Blocked by required conditions
CI / build-esp32-c3 (push) Blocked by required conditions
CI / build-esp32-c6 (push) Blocked by required conditions
CI / build-nrf52 (push) Blocked by required conditions
CI / build-rpi2040 (push) Blocked by required conditions
CI / build-stm32 (push) Blocked by required conditions
CI / build-debian-src (push) Waiting to run
CI / package-pio-deps-native-tft (push) Waiting to run
CI / test-native (push) Waiting to run
CI / docker-deb-amd64 (push) Waiting to run
CI / docker-deb-amd64-tft (push) Waiting to run
CI / docker-alp-amd64 (push) Waiting to run
CI / docker-alp-amd64-tft (push) Waiting to run
CI / docker-deb-arm64 (push) Waiting to run
CI / docker-deb-armv7 (push) Waiting to run
CI / gather-artifacts (esp32) (push) Blocked by required conditions
CI / gather-artifacts (esp32c3) (push) Blocked by required conditions
CI / gather-artifacts (esp32c6) (push) Blocked by required conditions
CI / gather-artifacts (esp32s3) (push) Blocked by required conditions
CI / gather-artifacts (nrf52840) (push) Blocked by required conditions
CI / gather-artifacts (rp2040) (push) Blocked by required conditions
CI / gather-artifacts (stm32) (push) Blocked by required conditions
CI / release-artifacts (push) Blocked by required conditions
CI / release-firmware (esp32) (push) Blocked by required conditions
CI / release-firmware (esp32c3) (push) Blocked by required conditions
CI / release-firmware (esp32c6) (push) Blocked by required conditions
CI / release-firmware (esp32s3) (push) Blocked by required conditions
CI / release-firmware (nrf52840) (push) Blocked by required conditions
CI / release-firmware (rp2040) (push) Blocked by required conditions
CI / release-firmware (stm32) (push) Blocked by required conditions
CI / publish-firmware (push) Blocked by required conditions

* Allow observers to respond to AdminMessage requests
Ground work for CannedMessage getters and setters

* Enable CannedMessage config in apps for InkHUD devices

* Migrate the InkHUD::Events AdminModule observer
Use the new AdminModule_ObserverData struct

* Bare-bones NicheGraphics util to access canned messages
Handles loading and parsing. Handle admin messages for setting and getting.

* Send canned messages via on-screen menu

* Change ThreadedMessageApplet from Observer to Module API
Allows us to intercept locally generated packets ('loopbackOK = true'), to handle outgoing canned messages.

* Fix: crash getting empty canned message string via Client API

* Move file into Utils subdir

* Move an include statement from .cpp to .h

* Limit strncpy size of dest, not source
Wasn't critical in ths specific case, but definitely a mistake.
This commit is contained in:
todd-herbert 2025-06-25 23:04:18 +12:00 committed by GitHub
parent 91bcf072a0
commit ecfaf3a095
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 498 additions and 54 deletions

View File

@ -58,7 +58,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "mesh/Channels.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "meshUtils.h"
#include "modules/AdminModule.h"
#include "modules/ExternalNotificationModule.h"
#include "modules/TextMessageModule.h"
#include "modules/WaypointModule.h"
@ -1377,12 +1376,13 @@ int Screen::handleInputEvent(const InputEvent *event)
return 0;
}
int Screen::handleAdminMessage(const meshtastic_AdminMessage *arg)
int Screen::handleAdminMessage(AdminModule_ObserverData *arg)
{
switch (arg->which_payload_variant) {
switch (arg->request->which_payload_variant) {
// Node removed manually (i.e. via app)
case meshtastic_AdminMessage_remove_by_nodenum_tag:
setFrames(FOCUS_PRESERVE);
*arg->result = AdminMessageHandleResult::HANDLED;
break;
// Default no-op, in case the admin message observable gets used by other classes in future

View File

@ -78,6 +78,7 @@ class Screen
#include "concurrency/OSThread.h"
#include "input/InputBroker.h"
#include "mesh/MeshModule.h"
#include "modules/AdminModule.h"
#include "power.h"
#include <string>
#include <vector>
@ -193,8 +194,8 @@ class Screen : public concurrency::OSThread
CallbackObserver<Screen, const UIFrameEvent *>(this, &Screen::handleUIFrameEvent); // Sent by Mesh Modules
CallbackObserver<Screen, const InputEvent *> inputObserver =
CallbackObserver<Screen, const InputEvent *>(this, &Screen::handleInputEvent);
CallbackObserver<Screen, const meshtastic_AdminMessage *> adminMessageObserver =
CallbackObserver<Screen, const meshtastic_AdminMessage *>(this, &Screen::handleAdminMessage);
CallbackObserver<Screen, AdminModule_ObserverData *> adminMessageObserver =
CallbackObserver<Screen, AdminModule_ObserverData *>(this, &Screen::handleAdminMessage);
public:
explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY);
@ -544,7 +545,7 @@ class Screen : public concurrency::OSThread
int handleTextMessage(const meshtastic_MeshPacket *arg);
int handleUIFrameEvent(const UIFrameEvent *arg);
int handleInputEvent(const InputEvent *arg);
int handleAdminMessage(const meshtastic_AdminMessage *arg);
int handleAdminMessage(AdminModule_ObserverData *arg);
/// Used to force (super slow) eink displays to draw critical frames
void forceDisplay(bool forceUiUpdate = false);

View File

@ -19,6 +19,8 @@ namespace NicheGraphics::InkHUD
enum MenuAction {
NO_ACTION,
SEND_PING,
STORE_CANNEDMESSAGE_SELECTION,
SEND_CANNEDMESSAGE,
SHUTDOWN,
NEXT_TILE,
TOGGLE_BACKLIGHT,

View File

@ -5,6 +5,7 @@
#include "RTC.h"
#include "MeshService.h"
#include "Router.h"
#include "airtime.h"
#include "main.h"
#include "power.h"
@ -31,6 +32,12 @@ InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
if (settings->optionalMenuItems.backlight) {
backlight = Drivers::LatchingBacklight::getInstance();
}
// Initialize the Canned Message store
// This is a shared nicheGraphics component
// - handles loading & parsing the canned messages
// - handles setting / getting of canned messages via apps (Client API Admin Messages)
cm.store = CannedMessageStore::getInstance();
}
void InkHUD::MenuApplet::onForeground()
@ -65,6 +72,10 @@ void InkHUD::MenuApplet::onForeground()
void InkHUD::MenuApplet::onBackground()
{
// Discard any data we generated while selecting a canned message
// Frees heap mem
freeCannedMessageResources();
// If device has a backlight which isn't controlled by aux button:
// Item in options submenu allows keeping backlight on after menu is closed
// If this item is deselected we will turn backlight off again, now that menu is closing
@ -153,6 +164,16 @@ void InkHUD::MenuApplet::execute(MenuItem item)
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL);
break;
case STORE_CANNEDMESSAGE_SELECTION:
cm.selectedMessageItem = &cm.messageItems.at(cursor - 1); // Minus one: offset for the initial "Send Ping" entry
break;
case SEND_CANNEDMESSAGE:
cm.selectedRecipientItem = &cm.recipientItems.at(cursor);
sendText(cm.selectedRecipientItem->dest, cm.selectedRecipientItem->channelIndex, cm.selectedMessageItem->rawText.c_str());
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL); // Next refresh should be FULL. Lots of button pressing to get here
break;
case ROTATE:
inkhud->rotate();
break;
@ -260,9 +281,11 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
break;
case SEND:
items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));
// Todo: canned messages
items.push_back(MenuItem("Exit", MenuPage::EXIT));
populateSendPage();
break;
case CANNEDMESSAGE_RECIPIENT:
populateRecipientPage();
break;
case OPTIONS:
@ -497,6 +520,8 @@ void InkHUD::MenuApplet::populateAutoshowPage()
}
}
// Create MenuItem entries to select our definition of "Recent"
// Controls how long data will remain in any "Recents" flavored applets
void InkHUD::MenuApplet::populateRecentsPage()
{
// How many values are shown for use to choose from
@ -510,6 +535,112 @@ void InkHUD::MenuApplet::populateRecentsPage()
}
}
// MenuItem entries for the "send" page
// Dynamically creates menu items based on available canned messages
void InkHUD::MenuApplet::populateSendPage()
{
// Position / NodeInfo packet
items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));
// One menu item for each canned message
uint8_t count = cm.store->size();
for (uint8_t i = 0; i < count; i++) {
// Gather the information for this item
CannedMessages::MessageItem messageItem;
messageItem.rawText = cm.store->at(i);
messageItem.label = parse(messageItem.rawText);
// Store the item (until the menu closes)
cm.messageItems.push_back(messageItem);
// Create a menu item
const char *itemText = cm.messageItems.back().label.c_str();
items.push_back(MenuItem(itemText, MenuAction::STORE_CANNEDMESSAGE_SELECTION, MenuPage::CANNEDMESSAGE_RECIPIENT));
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
}
// Dynamically create MenuItem entries for possible canned message destinations
// All available channels are shown
// Favorite nodes are shown, provided we don't have an *excessive* amount
void InkHUD::MenuApplet::populateRecipientPage()
{
// Create recipient data (and menu items) for any channels
// --------------------------------------------------------
for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) {
// Get the channel, and check if it's enabled
meshtastic_Channel &channel = channels.getByIndex(i);
if (!channel.has_settings || channel.role == meshtastic_Channel_Role_DISABLED)
continue;
CannedMessages::RecipientItem r;
// Set index
r.channelIndex = channel.index;
// Set a label for the menu item
r.label = "Ch " + to_string(i) + ": ";
if (channel.role == meshtastic_Channel_Role_PRIMARY)
r.label += "Primary";
else
r.label += parse(channel.settings.name);
// Add to the list of recipients
cm.recipientItems.push_back(r);
// Add a menu item for this recipient
const char *itemText = cm.recipientItems.back().label.c_str();
items.push_back(MenuItem(itemText, SEND_CANNEDMESSAGE, MenuPage::EXIT));
}
// Create recipient data (and menu items) for favorite nodes
// ---------------------------------------------------------
uint32_t nodeCount = nodeDB->getNumMeshNodes();
uint32_t favoriteCount = 0;
// Count favorites
for (uint32_t i = 0; i < nodeCount; i++) {
if (nodeDB->getMeshNodeByIndex(i)->is_favorite)
favoriteCount++;
}
// Only add favorites if the number is reasonable
// Don't want some monstrous list that takes 100 clicks to reach exit
if (favoriteCount < 20) {
for (uint32_t i = 0; i < nodeCount; i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Skip node if not a favorite
if (!node->is_favorite)
continue;
CannedMessages::RecipientItem r;
r.dest = node->num;
r.channelIndex = nodeDB->getMeshNodeChannel(node->num); // Channel index only relevant if encrypted DM not possible(?)
// Set a label for the menu item
r.label = "DM: ";
if (node->has_user)
r.label += parse(node->user.long_name);
else
r.label += hexifyNodeNum(node->num); // Unsure if it's possible to favorite a node without NodeInfo?
// Add to the list of recipients
cm.recipientItems.push_back(r);
// Add a menu item for this recipient
const char *itemText = cm.recipientItems.back().label.c_str();
items.push_back(MenuItem(itemText, SEND_CANNEDMESSAGE, MenuPage::EXIT));
}
}
items.push_back(MenuItem("Exit", MenuPage::EXIT));
}
// Renders the panel shown at the top of the root menu.
// Displays the clock, and several other pieces of instantaneous system info,
// which we'd prefer not to have displayed in a normal applet, as they update too frequently.
@ -619,4 +750,37 @@ uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight()
return height;
}
// Send a text message to the mesh
// Used to send our canned messages
void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message)
{
meshtastic_MeshPacket *p = router->allocForSending();
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p->to = dest;
p->channel = channel;
p->want_ack = true;
p->decoded.payload.size = strlen(message);
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
// Tack on a bell character if requested
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 character
p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator
p->decoded.payload.size++;
}
LOG_INFO("Send message id=%d, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes);
service->sendToMesh(p, RX_SRC_LOCAL, true); // Send to mesh, cc to phone
}
// Free up any heap mmemory we'd used while selecting / sending canned messages
void InkHUD::MenuApplet::freeCannedMessageResources()
{
cm.selectedMessageItem = nullptr;
cm.selectedRecipientItem = nullptr;
cm.messageItems.clear();
cm.recipientItems.clear();
}
#endif

View File

@ -6,10 +6,12 @@
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/Persistence.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
#include "graphics/niche/Utils/CannedMessageStore.h"
#include "./MenuItem.h"
#include "./MenuPage.h"
#include "Channels.h"
#include "concurrency/OSThread.h"
namespace NicheGraphics::InkHUD
@ -36,12 +38,18 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
void execute(MenuItem item); // Perform the MenuAction associated with a MenuItem, if any
void showPage(MenuPage page); // Load and display a MenuPage
void populateSendPage(); // Dynamically create MenuItems including canned messages
void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message
void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets
void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
uint16_t getSystemInfoPanelHeight();
void drawSystemInfoPanel(int16_t left, int16_t top, uint16_t width,
uint16_t *height = nullptr); // Info panel at top of root menu
uint16_t *height = nullptr); // Info panel at top of root menu
void sendText(NodeNum dest, ChannelIndex channel, const char *message); // Send a text message to mesh
void freeCannedMessageResources(); // Clear MenuApplet's canned message processing data
MenuPage currentPage = MenuPage::ROOT;
uint8_t cursor = 0; // Which menu item is currently highlighted
@ -51,6 +59,37 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
std::vector<MenuItem> items; // MenuItems for the current page. Filled by ShowPage
// Data for selecting and sending canned messages via the menu
// Placed into a sub-class for organization only
class CannedMessages
{
public:
// Share NicheGraphics component
// Handles loading, getting, setting
CannedMessageStore *store;
// One canned message
// Links the menu item to the true message text
struct MessageItem {
std::string label; // Shown in menu. Prefixed, and UTF-8 chars parsed
std::string rawText; // The message which will be sent, if this item is selected
} *selectedMessageItem;
// One possible destination for a canned message
// Links the menu item to the intended recipient
// May represent either broadcast or DM
struct RecipientItem {
std::string label; // Shown in menu
NodeNum dest = NODENUM_BROADCAST;
uint8_t channelIndex = 0;
} *selectedRecipientItem;
// These lists are generated when the menu page is populated
// Cleared onBackground (when MenuApplet closes)
std::vector<MessageItem> messageItems;
std::vector<RecipientItem> recipientItems;
} cm;
Applet *borrowedTileOwner = nullptr; // Which applet we have temporarily replaced while displaying menu
};

View File

@ -18,6 +18,7 @@ namespace NicheGraphics::InkHUD
enum MenuPage : uint8_t {
ROOT, // Initial menu page
SEND,
CANNEDMESSAGE_RECIPIENT, // Select destination for a canned message
OPTIONS,
APPLETS,
AUTOSHOW,

View File

@ -13,7 +13,8 @@ using namespace NicheGraphics;
constexpr uint8_t MAX_MESSAGES_SAVED = 10;
constexpr uint32_t MAX_MESSAGE_SIZE = 250;
InkHUD::ThreadedMessageApplet::ThreadedMessageApplet(uint8_t channelIndex) : channelIndex(channelIndex)
InkHUD::ThreadedMessageApplet::ThreadedMessageApplet(uint8_t channelIndex)
: SinglePortModule("ThreadedMessageApplet", meshtastic_PortNum_TEXT_MESSAGE_APP), channelIndex(channelIndex)
{
// Create the message store
// Will shortly attempt to load messages from RAM, if applet is active
@ -69,9 +70,8 @@ void InkHUD::ThreadedMessageApplet::onRender()
// Grab data for message
MessageStore::Message &m = store->messages.at(i);
bool outgoing = (m.sender == 0);
meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(m.sender);
std::string bodyText = parse(m.text); // Parse any non-ascii chars in the message
bool outgoing = (m.sender == 0) || (m.sender == myNodeInfo.my_node_num); // Own NodeNum if canned message
std::string bodyText = parse(m.text); // Parse any non-ascii chars in the message
// Cache bottom Y of message text
// - Used when drawing vertical line alongside
@ -171,54 +171,54 @@ void InkHUD::ThreadedMessageApplet::onRender()
void InkHUD::ThreadedMessageApplet::onActivate()
{
loadMessagesFromFlash();
textMessageObserver.observe(textMessageModule); // Begin handling any new text messages with onReceiveTextMessage
loopbackOk = true; // Allow us to handle messages generated on the node (canned messages)
}
// Code which runs when the applet stop running
// This might be happen at shutdown, or if user disables the applet at run-time
// This might be at shutdown, or if the user disables the applet at run-time, via the menu
void InkHUD::ThreadedMessageApplet::onDeactivate()
{
textMessageObserver.unobserve(textMessageModule); // Stop handling any new text messages with onReceiveTextMessage
loopbackOk = false; // Slightly reduce our impact if the applet is disabled
}
// Handle new text messages
// These might be incoming, from the mesh, or outgoing from phone
// Each instance of the ThreadMessageApplet will only listen on one specific channel
// Method should return 0, to indicate general success to TextMessageModule
int InkHUD::ThreadedMessageApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p)
ProcessMessage InkHUD::ThreadedMessageApplet::handleReceived(const meshtastic_MeshPacket &mp)
{
// Abort if applet fully deactivated
// Already handled by onActivate and onDeactivate, but good practice for all applets
if (!isActive())
return 0;
return ProcessMessage::CONTINUE;
// Abort if wrong channel
if (p->channel != this->channelIndex)
return 0;
if (mp.channel != this->channelIndex)
return ProcessMessage::CONTINUE;
// Abort if message was a DM
if (p->to != NODENUM_BROADCAST)
return 0;
if (mp.to != NODENUM_BROADCAST)
return ProcessMessage::CONTINUE;
// Extract info into our slimmed-down "StoredMessage" type
MessageStore::Message newMessage;
newMessage.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); // Current RTC time
newMessage.sender = p->from;
newMessage.channelIndex = p->channel;
newMessage.text = std::string(&p->decoded.payload.bytes[0], &p->decoded.payload.bytes[p->decoded.payload.size]);
newMessage.sender = mp.from;
newMessage.channelIndex = mp.channel;
newMessage.text = std::string((const char *)mp.decoded.payload.bytes, mp.decoded.payload.size);
// Store newest message at front
// These records are used when rendering, and also stored in flash at shutdown
store->messages.push_front(newMessage);
// If this was an incoming message, suggest that our applet becomes foreground, if permitted
if (getFrom(p) != nodeDB->getNodeNum())
if (getFrom(&mp) != nodeDB->getNodeNum())
requestAutoshow();
// Redraw the applet, perhaps.
requestUpdate(); // Want to update display, if applet is foreground
return 0;
// Tell Module API to continue informing other firmware components about this message
// We're not the only component which is interested in new text messages
return ProcessMessage::CONTINUE;
}
// Don't show notifications for text messages broadcast to our channel, when the applet is displayed

View File

@ -30,7 +30,7 @@ namespace NicheGraphics::InkHUD
class Applet;
class ThreadedMessageApplet : public Applet
class ThreadedMessageApplet : public Applet, public SinglePortModule
{
public:
explicit ThreadedMessageApplet(uint8_t channelIndex);
@ -41,16 +41,11 @@ class ThreadedMessageApplet : public Applet
void onActivate() override;
void onDeactivate() override;
void onShutdown() override;
int onReceiveTextMessage(const meshtastic_MeshPacket *p);
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
bool approveNotification(Notification &n) override; // Which notifications to suppress
protected:
// Used to register our text message callback
CallbackObserver<ThreadedMessageApplet, const meshtastic_MeshPacket *> textMessageObserver =
CallbackObserver<ThreadedMessageApplet, const meshtastic_MeshPacket *>(this,
&ThreadedMessageApplet::onReceiveTextMessage);
void saveMessagesToFlash();
void loadMessagesFromFlash();

View File

@ -4,14 +4,13 @@
#include "RTC.h"
#include "buzz.h"
#include "modules/AdminModule.h"
#include "modules/ExternalNotificationModule.h"
#include "modules/TextMessageModule.h"
#include "sleep.h"
#include "./Applet.h"
#include "./SystemApplet.h"
#include "graphics/niche/FlashData.h"
#include "graphics/niche/Utils/FlashData.h"
using namespace NicheGraphics;
@ -30,7 +29,7 @@ void InkHUD::Events::begin()
rebootObserver.observe(&notifyReboot);
textMessageObserver.observe(textMessageModule);
#if !MESHTASTIC_EXCLUDE_ADMIN
adminMessageObserver.observe(adminModule);
adminMessageObserver.observe((Observable<AdminModule_ObserverData *> *)adminModule);
#endif
#ifdef ARCH_ESP32
lightSleepObserver.observe(&notifyLightSleep);
@ -193,14 +192,15 @@ int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet)
return 0; // Tell caller to continue notifying other observers. (No reason to abort this event)
}
int InkHUD::Events::onAdminMessage(const meshtastic_AdminMessage *message)
int InkHUD::Events::onAdminMessage(AdminModule_ObserverData *data)
{
switch (message->which_payload_variant) {
switch (data->request->which_payload_variant) {
// Factory reset
// Two possible messages. One preserves BLE bonds, other wipes. Both should clear InkHUD data.
case meshtastic_AdminMessage_factory_reset_device_tag:
case meshtastic_AdminMessage_factory_reset_config_tag:
eraseOnReboot = true;
*data->result = AdminMessageHandleResult::HANDLED;
break;
default:

View File

@ -13,7 +13,7 @@ however this class handles general events which concern InkHUD as a whole, e.g.
#include "configuration.h"
#include "Observer.h"
#include "modules/AdminModule.h"
#include "./InkHUD.h"
#include "./Persistence.h"
@ -33,7 +33,7 @@ class Events
int beforeDeepSleep(void *unused); // Prepare for shutdown
int beforeReboot(void *unused); // Prepare for reboot
int onReceiveTextMessage(const meshtastic_MeshPacket *packet); // Store most recent text message
int onAdminMessage(const meshtastic_AdminMessage *message); // Handle incoming admin messages
int onAdminMessage(AdminModule_ObserverData *data); // Handle incoming admin messages
#ifdef ARCH_ESP32
int beforeLightSleep(void *unused); // Prepare for light sleep
#endif
@ -54,8 +54,8 @@ class Events
CallbackObserver<Events, const meshtastic_MeshPacket *>(this, &Events::onReceiveTextMessage);
// Get notified of incoming admin messages, and handle any which are relevant to InkHUD
CallbackObserver<Events, const meshtastic_AdminMessage *> adminMessageObserver =
CallbackObserver<Events, const meshtastic_AdminMessage *>(this, &Events::onAdminMessage);
CallbackObserver<Events, AdminModule_ObserverData *> adminMessageObserver =
CallbackObserver<Events, AdminModule_ObserverData *>(this, &Events::onAdminMessage);
#ifdef ARCH_ESP32
// Get notified when the system is entering light sleep

View File

@ -15,8 +15,8 @@ The save / load mechanism is a shared NicheGraphics feature.
#include "configuration.h"
#include "./InkHUD.h"
#include "graphics/niche/FlashData.h"
#include "graphics/niche/InkHUD/MessageStore.h"
#include "graphics/niche/Utils/FlashData.h"
namespace NicheGraphics::InkHUD
{

View File

@ -0,0 +1,163 @@
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "./CannedMessageStore.h"
#include "FSCommon.h"
#include "NodeDB.h"
#include "SPILock.h"
#include "generated/meshtastic/cannedmessages.pb.h"
using namespace NicheGraphics;
// Location of the file which stores the canned messages on flash
static const char *cannedMessagesConfigFile = "/prefs/cannedConf.proto";
CannedMessageStore::CannedMessageStore()
{
#if !MESHTASTIC_EXCLUDE_ADMIN
adminMessageObserver.observe(adminModule);
#endif
// Load & parse messages from flash
load();
}
// Get access to (or create) the singleton instance of this class
CannedMessageStore *CannedMessageStore::getInstance()
{
// Instantiate the class the first time this method is called
static CannedMessageStore *const singletonInstance = new CannedMessageStore;
return singletonInstance;
}
// Access canned messages by index
// Consumer should check CannedMessageStore::size to avoid accessing out of bounds
const std::string &CannedMessageStore::at(uint8_t i)
{
assert(i < messages.size());
return messages.at(i);
}
// Number of canned message strings available
uint8_t CannedMessageStore::size()
{
return messages.size();
}
// Load canned message data from flash, and parse into the individual strings
void CannedMessageStore::load()
{
// In case we're reloading
messages.clear();
// Attempt to load the bulk canned message data from flash
meshtastic_CannedMessageModuleConfig cannedMessageModuleConfig;
LoadFileResult result = nodeDB->loadProto("/prefs/cannedConf.proto", meshtastic_CannedMessageModuleConfig_size,
sizeof(meshtastic_CannedMessageModuleConfig),
&meshtastic_CannedMessageModuleConfig_msg, &cannedMessageModuleConfig);
// Abort if nothing to load
if (result != LoadFileResult::LOAD_SUCCESS || strlen(cannedMessageModuleConfig.messages) == 0)
return;
// Split into individual canned messages
// These are concatenated when stored in flash, using '|' as a delimiter
std::string s;
for (char c : cannedMessageModuleConfig.messages) { // Character by character
// If found end of a string
if (c == '|' || c == '\0') {
// Copy into the vector (if non-empty)
if (!s.empty())
messages.push_back(s);
// Reset the string builder
s.clear();
// End of data, all strings processed
if (c == 0)
break;
}
// Otherwise, append char (continue building string)
else
s.push_back(c);
}
}
// Handle incoming admin messages
// We get these as an observer of AdminModule
// It's our responsibility to handle setting and getting of canned messages via the client API
// Ordinarily, this would be handled by the CannedMessageModule, but it is bound to Screen.cpp, so not suitable for NicheGraphics
int CannedMessageStore::onAdminMessage(AdminModule_ObserverData *data)
{
switch (data->request->which_payload_variant) {
// Client API changing the canned messages
case meshtastic_AdminMessage_set_canned_message_module_messages_tag:
handleSet(data->request);
*data->result = AdminMessageHandleResult::HANDLED;
break;
// Client API wants to know the current canned messages
case meshtastic_AdminMessage_get_canned_message_module_messages_request_tag:
handleGet(data->response);
*data->result = AdminMessageHandleResult::HANDLED_WITH_RESPONSE;
break;
default:
break;
}
return 0; // Tell caller to continue notifying other observers. (No reason to abort this event)
}
// Client API changing the canned messages
void CannedMessageStore::handleSet(const meshtastic_AdminMessage *request)
{
// Copy into the correct struct (for writing to flash as protobuf)
meshtastic_CannedMessageModuleConfig cannedMessageModuleConfig;
strncpy(cannedMessageModuleConfig.messages, request->set_canned_message_module_messages,
sizeof(cannedMessageModuleConfig.messages));
// Ensure the directory exists
#ifdef FSCom
spiLock->lock();
FSCom.mkdir("/prefs");
spiLock->unlock();
#endif
// Write to flash
nodeDB->saveProto(cannedMessagesConfigFile, meshtastic_CannedMessageModuleConfig_size,
&meshtastic_CannedMessageModuleConfig_msg, &cannedMessageModuleConfig);
// Reload from flash, to update the canned messages in RAM
// (This is a lazy way to handle it)
load();
}
// Client API wants to know the current canned messages
// We're reconstructing the monolithic canned message string from our copy of the messages in RAM
// Lazy, but more convenient that reloading the monolithic string from flash just for this
void CannedMessageStore::handleGet(meshtastic_AdminMessage *response)
{
// Merge the canned messages back into the delimited format expected
std::string merged;
if (!messages.empty()) { // Don't run if no messages: error on pop_back with size=0
merged.reserve(201);
for (std::string &s : messages) {
merged += s;
merged += '|';
}
merged.pop_back(); // Drop the final delimiter (loop added one too many)
}
// Place the data into the response
// This response is scoped to AdminModule::handleReceivedProtobuf
// We were passed reference to it via the observable
response->which_payload_variant = meshtastic_AdminMessage_get_canned_message_module_messages_response_tag;
strncpy(response->get_canned_message_module_messages_response, merged.c_str(), strlen(merged.c_str()) + 1);
}
#endif

View File

@ -0,0 +1,54 @@
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
/*
Re-usable NicheGraphics tool
Makes canned message data accessible to any NicheGraphics UI.
- handles loading & parsing from flash
- handles the admin messages for setting & getting canned messages via client API (phone apps, etc)
The original CannedMessageModule class is bound to Screen.cpp,
making it incompatible with the NicheGraphics framework, which suppresses Screen.cpp
This implementation aims to be self-contained.
The necessary interaction with the AdminModule is done as an observer.
*/
#pragma once
#include "configuration.h"
#include "modules/AdminModule.h"
namespace NicheGraphics
{
class CannedMessageStore
{
public:
static CannedMessageStore *getInstance(); // Create or get the singleton instance
const std::string &at(uint8_t i); // Get canned message at index
uint8_t size(); // Get total number of canned messages
int onAdminMessage(AdminModule_ObserverData *data); // Handle incoming admin messages
private:
CannedMessageStore(); // Constructor made private: force use of CannedMessageStore::instance()
void load(); // Load from flash, and parse
void handleSet(const meshtastic_AdminMessage *request); // Client API changing the canned messages
void handleGet(meshtastic_AdminMessage *response); // Client API wants to know current canned messages
std::vector<std::string> messages;
// Get notified of incoming admin messages, to get / set canned messages
CallbackObserver<CannedMessageStore, AdminModule_ObserverData *> adminMessageObserver =
CallbackObserver<CannedMessageStore, AdminModule_ObserverData *>(this, &CannedMessageStore::onAdminMessage);
};
}; // namespace NicheGraphics
#endif

View File

@ -1422,7 +1422,7 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AUDIO_CONFIG;
#endif
// Option to explicitly include canned messages for edge cases, e.g. niche graphics
#if (!HAS_SCREEN || NO_EXT_GPIO) || MESHTASTIC_EXCLUDE_CANNEDMESSAGES
#if ((!HAS_SCREEN || NO_EXT_GPIO) || MESHTASTIC_EXCLUDE_CANNEDMESSAGES) && !defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_CANNEDMSG_CONFIG;
#endif
#if NO_EXT_GPIO

View File

@ -470,22 +470,38 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
setPassKey(&res);
myReply = allocDataProtobuf(res);
} else if (mp.decoded.want_response) {
LOG_DEBUG("Did not responded to a request that wanted a respond. req.variant=%d", r->which_payload_variant);
LOG_DEBUG("Module API did not respond to admin message. req.variant=%d", r->which_payload_variant);
} else if (handleResult != AdminMessageHandleResult::HANDLED) {
// Probably a message sent by us or sent to our local node. FIXME, we should avoid scanning these messages
LOG_DEBUG("Ignore irrelevant admin %d", r->which_payload_variant);
LOG_DEBUG("Module API did not handle admin message %d", r->which_payload_variant);
}
break;
}
// Allow any observers (e.g. the UI) to handle/respond
AdminMessageHandleResult observerResult = AdminMessageHandleResult::NOT_HANDLED;
meshtastic_AdminMessage observerResponse = meshtastic_AdminMessage_init_default;
AdminModule_ObserverData observerData = {
.request = r,
.response = &observerResponse,
.result = &observerResult,
};
notifyObservers(&observerData);
if (observerResult == AdminMessageHandleResult::HANDLED_WITH_RESPONSE) {
setPassKey(&observerResponse);
myReply = allocDataProtobuf(observerResponse);
LOG_DEBUG("Observer responded to admin message");
} else if (observerResult == AdminMessageHandleResult::HANDLED) {
LOG_DEBUG("Observer handled admin message");
}
// If asked for a response and it is not yet set, generate an 'ACK' response
if (mp.decoded.want_response && !myReply) {
myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp);
}
// Allow any observers (e.g. the UI) to respond to this event
notifyObservers(r);
return handled;
}

View File

@ -6,10 +6,19 @@
#include "mesh/wifi/WiFiAPClient.h"
#endif
/**
* Datatype passed to Observers by AdminModule, to allow external handling of admin messages
*/
struct AdminModule_ObserverData {
const meshtastic_AdminMessage *request;
meshtastic_AdminMessage *response;
AdminMessageHandleResult *result;
};
/**
* Admin module for admin messages
*/
class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Observable<const meshtastic_AdminMessage *>
class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Observable<AdminModule_ObserverData *>
{
public:
/** Constructor