#include "configuration.h" #if ARCH_PORTDUINO #include "PortduinoGlue.h" #endif #if HAS_SCREEN #include "CannedMessageModule.h" #include "Channels.h" #include "FSCommon.h" #include "MeshService.h" #include "NodeDB.h" #include "PowerFSM.h" // needed for button bypass #include "detect/ScanI2C.h" #include "input/ScanAndSelect.h" #include "mesh/generated/meshtastic/cannedmessages.pb.h" #include "main.h" // for cardkb_found #include "modules/ExternalNotificationModule.h" // for buzzer control #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" #endif #if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY) #include "graphics/EInkDynamicDisplay.h" // To select between full and fast refresh on E-Ink displays #endif #ifndef INPUTBROKER_MATRIX_TYPE #define INPUTBROKER_MATRIX_TYPE 0 #endif #include "graphics/ScreenFonts.h" // Remove Canned message screen if no action is taken for some milliseconds #define INACTIVATE_AFTER_MS 20000 extern ScanI2C::DeviceAddress cardkb_found; static const char *cannedMessagesConfigFile = "/prefs/cannedConf.proto"; meshtastic_CannedMessageModuleConfig cannedMessageModuleConfig; CannedMessageModule *cannedMessageModule; CannedMessageModule::CannedMessageModule() : SinglePortModule("canned", meshtastic_PortNum_TEXT_MESSAGE_APP), concurrency::OSThread("CannedMessageModule") { if (moduleConfig.canned_message.enabled || CANNED_MESSAGE_MODULE_ENABLE) { this->loadProtoForModule(); if ((this->splitConfiguredMessages() <= 0) && (cardkb_found.address == 0x00) && !INPUTBROKER_MATRIX_TYPE && !CANNED_MESSAGE_MODULE_ENABLE) { LOG_INFO("CannedMessageModule: No messages are configured. Module is disabled\n"); this->runState = CANNED_MESSAGE_RUN_STATE_DISABLED; disable(); } else { LOG_INFO("CannedMessageModule is enabled\n"); // T-Watch interface currently has no way to select destination type, so default to 'node' #if defined(T_WATCH_S3) || defined(RAK14014) this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NODE; #endif this->inputObserver.observe(inputBroker); } } else { this->runState = CANNED_MESSAGE_RUN_STATE_DISABLED; disable(); } } /** * @brief Items in array this->messages will be set to be pointing on the right * starting points of the string this->messageStore * * @return int Returns the number of messages found. */ // FIXME: This is just one set of messages now int CannedMessageModule::splitConfiguredMessages() { int messageIndex = 0; int i = 0; String messages = cannedMessageModuleConfig.messages; #if defined(T_WATCH_S3) || defined(RAK14014) String separator = messages.length() ? "|" : ""; messages = "[---- Free Text ----]" + separator + messages; #endif // collect all the message parts strncpy(this->messageStore, messages.c_str(), sizeof(this->messageStore)); // The first message points to the beginning of the store. this->messages[messageIndex++] = this->messageStore; int upTo = strlen(this->messageStore) - 1; while (i < upTo) { if (this->messageStore[i] == '|') { // Message ending found, replace it with string-end character. this->messageStore[i] = '\0'; // hit our max messages, bail if (messageIndex >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT) { this->messagesCount = messageIndex; return this->messagesCount; } // Next message starts after pipe (|) just found. this->messages[messageIndex++] = (this->messageStore + i + 1); } i += 1; } if (strlen(this->messages[messageIndex - 1]) > 0) { // We have a last message. LOG_DEBUG("CannedMessage %d is: '%s'\n", messageIndex - 1, this->messages[messageIndex - 1]); this->messagesCount = messageIndex; } else { this->messagesCount = messageIndex - 1; } return this->messagesCount; } int CannedMessageModule::handleInputEvent(const InputEvent *event) { if ((strlen(moduleConfig.canned_message.allow_input_source) > 0) && (strcasecmp(moduleConfig.canned_message.allow_input_source, event->source) != 0) && (strcasecmp(moduleConfig.canned_message.allow_input_source, "_any") != 0)) { // Event source is not accepted. // Event only accepted if source matches the configured one, or // the configured one is "_any" (or if there is no configured // source at all) return 0; } if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) { return 0; // Ignore input while sending } bool validEvent = false; if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP)) { if (this->messagesCount > 0) { this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_UP; validEvent = true; } } if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN)) { if (this->messagesCount > 0) { this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_DOWN; validEvent = true; } } if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT)) { #if defined(T_WATCH_S3) || defined(RAK14014) if (this->currentMessageIndex == 0) { this->runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; requestFocus(); // Tell Screen::setFrames to move to our module's frame, next time it runs UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen this->notifyObservers(&e); return 0; } #endif // when inactive, call the onebutton shortpress instead. Activate Module only on up/down if ((this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED)) { powerFSM.trigger(EVENT_PRESS); } else { this->payload = this->runState; this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; validEvent = true; } } if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL)) { UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen this->currentMessageIndex = -1; #if !defined(T_WATCH_S3) && !defined(RAK14014) this->freetext = ""; // clear freetext this->cursor = 0; this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; #endif this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; this->notifyObservers(&e); } if ((event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK)) || (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) || (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT))) { #if defined(T_WATCH_S3) || defined(RAK14014) if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) { this->payload = 0xb4; } else if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT)) { this->payload = 0xb7; } #else // tweak for left/right events generated via trackball/touch with empty kbchar if (!event->kbchar) { if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) { this->payload = 0xb4; } else if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT)) { this->payload = 0xb7; } } else { // pass the pressed key this->payload = event->kbchar; } #endif this->lastTouchMillis = millis(); validEvent = true; } if (event->inputEvent == static_cast(ANYKEY)) { // when inactive, this will switch to the freetext mode if ((this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED)) { this->runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; } validEvent = false; // If key is normal than it will be set to true. // Run modifier key code below, (doesnt inturrupt typing or reset to start screen page) switch (event->kbchar) { case 0x11: // make screen brighter if (screen) screen->increaseBrightness(); LOG_DEBUG("increasing Screen Brightness\n"); break; case 0x12: // make screen dimmer if (screen) screen->decreaseBrightness(); LOG_DEBUG("Decreasing Screen Brightness\n"); break; case 0xf1: // draw modifier (function) symbal if (screen) screen->setFunctionSymbal("Fn"); break; case 0xf2: // remove modifier (function) symbal if (screen) screen->removeFunctionSymbal("Fn"); break; // mute (switch off/toggle) external notifications on fn+m case 0xac: if (moduleConfig.external_notification.enabled == true) { if (externalNotificationModule->getMute()) { externalNotificationModule->setMute(false); showTemporaryMessage("Notifications \nEnabled"); if (screen) screen->removeFunctionSymbal("M"); // remove the mute symbol from the bottom right corner } else { externalNotificationModule->stopNow(); // this will turn off all GPIO and sounds and idle the loop externalNotificationModule->setMute(true); showTemporaryMessage("Notifications \nDisabled"); if (screen) screen->setFunctionSymbal("M"); // add the mute symbol to the bottom right corner } } break; case 0x9e: // toggle GPS like triple press does #if !MESHTASTIC_EXCLUDE_GPS if (gps != nullptr) { gps->toggleGpsMode(); } if (screen) screen->forceDisplay(); showTemporaryMessage("GPS Toggled"); #endif break; case 0xaf: // fn+space send network ping like double press does service->refreshLocalMeshNode(); if (service->trySendPosition(NODENUM_BROADCAST, true)) { showTemporaryMessage("Position \nUpdate Sent"); } else { showTemporaryMessage("Node Info \nUpdate Sent"); } break; default: // pass the pressed key // LOG_DEBUG("Canned message ANYKEY (%x)\n", event->kbchar); this->payload = event->kbchar; this->lastTouchMillis = millis(); validEvent = true; break; } if (screen && (event->kbchar != 0xf1)) { screen->removeFunctionSymbal("Fn"); // remove modifier (function) symbal } } #if defined(T_WATCH_S3) || defined(RAK14014) if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) { String keyTapped = keyForCoordinates(event->touchX, event->touchY); if (keyTapped == "⇧") { this->highlight = -1; this->payload = 0x00; validEvent = true; this->shift = !this->shift; } else if (keyTapped == "⌫") { this->highlight = keyTapped[0]; this->payload = 0x08; validEvent = true; this->shift = false; } else if (keyTapped == "123" || keyTapped == "ABC") { this->highlight = -1; this->payload = 0x00; this->charSet = this->charSet == 0 ? 1 : 0; validEvent = true; } else if (keyTapped == " ") { this->highlight = keyTapped[0]; this->payload = keyTapped[0]; validEvent = true; this->shift = false; } else if (keyTapped == "↵") { this->highlight = 0x00; this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT; this->currentMessageIndex = event->kbchar - 1; validEvent = true; this->shift = false; } else if (keyTapped != "") { this->highlight = keyTapped[0]; this->payload = this->shift ? keyTapped[0] : std::tolower(keyTapped[0]); validEvent = true; this->shift = false; } } #endif if (event->inputEvent == static_cast(MATRIXKEY)) { // this will send the text immediately on matrix press this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; this->payload = MATRIXKEY; this->currentMessageIndex = event->kbchar - 1; this->lastTouchMillis = millis(); validEvent = true; } if (validEvent) { requestFocus(); // Tell Screen::setFrames to move to our module's frame, next time it runs // Let runOnce to be called immediately. if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) { setIntervalFromNow(0); // on fast keypresses, this isn't fast enough. } else { runOnce(); } } return 0; } void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const char *message, bool wantReplies) { meshtastic_MeshPacket *p = allocDataPacket(); 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); 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'; // Bell character p->decoded.payload.size++; } // Only receive routing messages when expecting ACK for a canned message // Prevents the canned message module from regenerating the screen's frameset at unexpected times, // or raising a UIFrameEvent before another module has the chance this->waitingForAck = true; LOG_INFO("Sending message id=%d, dest=%x, msg=%.*s\n", 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. Even if there's no phone connected, this stores the message to match ACKs } int32_t CannedMessageModule::runOnce() { if (((!moduleConfig.canned_message.enabled) && !CANNED_MESSAGE_MODULE_ENABLE) || (this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) || (this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE)) { temporaryMessage = ""; return INT32_MAX; } // LOG_DEBUG("Check status\n"); UIFrameEvent e; if ((this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) || (this->runState == CANNED_MESSAGE_RUN_STATE_MESSAGE)) { // TODO: might have some feedback of sending state this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; temporaryMessage = ""; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen this->currentMessageIndex = -1; this->freetext = ""; // clear freetext this->cursor = 0; #if !defined(T_WATCH_S3) && !defined(RAK14014) this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; #endif this->notifyObservers(&e); } else if (((this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT)) && ((millis() - this->lastTouchMillis) > INACTIVATE_AFTER_MS)) { // Reset module e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen this->currentMessageIndex = -1; this->freetext = ""; // clear freetext this->cursor = 0; #if !defined(T_WATCH_S3) && !defined(RAK14014) this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; #endif this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; this->notifyObservers(&e); } else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) { if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { if (this->freetext.length() > 0) { sendText(this->dest, indexChannels[this->channel], this->freetext.c_str(), true); this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; } else { this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; } } else { if ((this->messagesCount > this->currentMessageIndex) && (strlen(this->messages[this->currentMessageIndex]) > 0)) { if (strcmp(this->messages[this->currentMessageIndex], "~") == 0) { powerFSM.trigger(EVENT_PRESS); return INT32_MAX; } else { #if defined(T_WATCH_S3) || defined(RAK14014) sendText(this->dest, indexChannels[this->channel], this->messages[this->currentMessageIndex], true); #else sendText(NODENUM_BROADCAST, channels.getPrimaryIndex(), this->messages[this->currentMessageIndex], true); #endif } this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; } else { // LOG_DEBUG("Reset message is empty.\n"); this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; } } e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen this->currentMessageIndex = -1; this->freetext = ""; // clear freetext this->cursor = 0; #if !defined(T_WATCH_S3) && !defined(RAK14014) this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; #endif this->notifyObservers(&e); return 2000; } else if ((this->runState != CANNED_MESSAGE_RUN_STATE_FREETEXT) && (this->currentMessageIndex == -1)) { this->currentMessageIndex = 0; LOG_DEBUG("First touch (%d):%s\n", this->currentMessageIndex, this->getCurrentMessage()); e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE; } else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_UP) { if (this->messagesCount > 0) { this->currentMessageIndex = getPrevIndex(); this->freetext = ""; // clear freetext this->cursor = 0; #if !defined(T_WATCH_S3) && !defined(RAK14014) this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; #endif this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE; LOG_DEBUG("MOVE UP (%d):%s\n", this->currentMessageIndex, this->getCurrentMessage()); } } else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_DOWN) { if (this->messagesCount > 0) { this->currentMessageIndex = this->getNextIndex(); this->freetext = ""; // clear freetext this->cursor = 0; #if !defined(T_WATCH_S3) && !defined(RAK14014) this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; #endif this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE; LOG_DEBUG("MOVE DOWN (%d):%s\n", this->currentMessageIndex, this->getCurrentMessage()); } } else if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) { switch (this->payload) { case 0xb4: // left if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NODE) { size_t numMeshNodes = nodeDB->getNumMeshNodes(); if (this->dest == NODENUM_BROADCAST) { this->dest = nodeDB->getNodeNum(); } for (unsigned int i = 0; i < numMeshNodes; i++) { if (nodeDB->getMeshNodeByIndex(i)->num == this->dest) { this->dest = (i > 0) ? nodeDB->getMeshNodeByIndex(i - 1)->num : nodeDB->getMeshNodeByIndex(numMeshNodes - 1)->num; break; } } if (this->dest == nodeDB->getNodeNum()) { this->dest = NODENUM_BROADCAST; } } else if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL) { for (unsigned int i = 0; i < channels.getNumChannels(); i++) { if ((channels.getByIndex(i).role == meshtastic_Channel_Role_SECONDARY) || (channels.getByIndex(i).role == meshtastic_Channel_Role_PRIMARY)) { indexChannels[numChannels] = i; numChannels++; } } if (this->channel == 0) { this->channel = numChannels - 1; } else { this->channel--; } } else { if (this->cursor > 0) { this->cursor--; } } break; case 0xb7: // right if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NODE) { size_t numMeshNodes = nodeDB->getNumMeshNodes(); if (this->dest == NODENUM_BROADCAST) { this->dest = nodeDB->getNodeNum(); } for (unsigned int i = 0; i < numMeshNodes; i++) { if (nodeDB->getMeshNodeByIndex(i)->num == this->dest) { this->dest = (i < numMeshNodes - 1) ? nodeDB->getMeshNodeByIndex(i + 1)->num : nodeDB->getMeshNodeByIndex(0)->num; break; } } if (this->dest == nodeDB->getNodeNum()) { this->dest = NODENUM_BROADCAST; } } else if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL) { for (unsigned int i = 0; i < channels.getNumChannels(); i++) { if ((channels.getByIndex(i).role == meshtastic_Channel_Role_SECONDARY) || (channels.getByIndex(i).role == meshtastic_Channel_Role_PRIMARY)) { indexChannels[numChannels] = i; numChannels++; } } if (this->channel == numChannels - 1) { this->channel = 0; } else { this->channel++; } } else { if (this->cursor < this->freetext.length()) { this->cursor++; } } break; default: break; } if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) { e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen switch (this->payload) { // code below all trigger the freetext window (where you type to send a message) or reset the // display back to the default window case 0x08: // backspace if (this->freetext.length() > 0 && this->highlight == 0x00) { if (this->cursor == this->freetext.length()) { this->freetext = this->freetext.substring(0, this->freetext.length() - 1); } else { this->freetext = this->freetext.substring(0, this->cursor - 1) + this->freetext.substring(this->cursor, this->freetext.length()); } this->cursor--; } break; case 0x09: // tab if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL) { this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; } else if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NODE) { this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL; } else { this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NODE; } break; case 0xb4: // left case 0xb7: // right // already handled above break; // handle fn+s for shutdown case 0x9b: if (screen) screen->startAlert("Shutting down..."); shutdownAtMsec = millis() + DEFAULT_SHUTDOWN_SECONDS * 1000; runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; break; // and fn+r for reboot case 0x90: if (screen) screen->startAlert("Rebooting..."); rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; break; default: if (this->highlight != 0x00) { break; } if (this->cursor == this->freetext.length()) { this->freetext += this->payload; } else { this->freetext = this->freetext.substring(0, this->cursor) + this->payload + this->freetext.substring(this->cursor); } this->cursor += 1; uint16_t maxChars = meshtastic_Constants_DATA_PAYLOAD_LEN - (moduleConfig.canned_message.send_bell ? 1 : 0); if (this->freetext.length() > maxChars) { this->cursor = maxChars; this->freetext = this->freetext.substring(0, maxChars); } break; } if (screen) screen->removeFunctionSymbal("Fn"); } this->lastTouchMillis = millis(); this->notifyObservers(&e); return INACTIVATE_AFTER_MS; } if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) { this->lastTouchMillis = millis(); this->notifyObservers(&e); return INACTIVATE_AFTER_MS; } return INT32_MAX; } const char *CannedMessageModule::getCurrentMessage() { return this->messages[this->currentMessageIndex]; } const char *CannedMessageModule::getPrevMessage() { return this->messages[this->getPrevIndex()]; } const char *CannedMessageModule::getNextMessage() { return this->messages[this->getNextIndex()]; } const char *CannedMessageModule::getMessageByIndex(int index) { return (index >= 0 && index < this->messagesCount) ? this->messages[index] : ""; } const char *CannedMessageModule::getNodeName(NodeNum node) { if (node == NODENUM_BROADCAST) { return "Broadcast"; } else { meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node); if (info != NULL) { return info->user.long_name; } else { return "Unknown"; } } } bool CannedMessageModule::shouldDraw() { if (!moduleConfig.canned_message.enabled && !CANNED_MESSAGE_MODULE_ENABLE) { return false; } // If using "scan and select" input, don't draw the module frame just to say "disabled" // The scanAndSelectInput class will draw its own temporary alert for user, when the input button is pressed else if (scanAndSelectInput != nullptr && !hasMessages()) return false; return (currentMessageIndex != -1) || (this->runState != CANNED_MESSAGE_RUN_STATE_INACTIVE); } // Has the user defined any canned messages? // Expose publicly whether canned message module is ready for use bool CannedMessageModule::hasMessages() { return (this->messagesCount > 0); } int CannedMessageModule::getNextIndex() { if (this->currentMessageIndex >= (this->messagesCount - 1)) { return 0; } else { return this->currentMessageIndex + 1; } } int CannedMessageModule::getPrevIndex() { if (this->currentMessageIndex <= 0) { return this->messagesCount - 1; } else { return this->currentMessageIndex - 1; } } void CannedMessageModule::showTemporaryMessage(const String &message) { temporaryMessage = message; UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen notifyObservers(&e); runState = CANNED_MESSAGE_RUN_STATE_MESSAGE; // run this loop again in 2 seconds, next iteration will clear the display setIntervalFromNow(2000); } #if defined(T_WATCH_S3) || defined(RAK14014) String CannedMessageModule::keyForCoordinates(uint x, uint y) { int outerSize = *(&this->keyboard[this->charSet] + 1) - this->keyboard[this->charSet]; for (int8_t outerIndex = 0; outerIndex < outerSize; outerIndex++) { int innerSize = *(&this->keyboard[this->charSet][outerIndex] + 1) - this->keyboard[this->charSet][outerIndex]; for (int8_t innerIndex = 0; innerIndex < innerSize; innerIndex++) { Letter letter = this->keyboard[this->charSet][outerIndex][innerIndex]; if (x > letter.rectX && x < (letter.rectX + letter.rectWidth) && y > letter.rectY && y < (letter.rectY + letter.rectHeight)) { return letter.character; } } } return ""; } void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { int outerSize = *(&this->keyboard[this->charSet] + 1) - this->keyboard[this->charSet]; int xOffset = 0; int yOffset = 56; display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); display->setColor(OLEDDISPLAY_COLOR::WHITE); display->drawStringMaxWidth(0, 0, display->getWidth(), cannedMessageModule->drawWithCursor(cannedMessageModule->freetext, cannedMessageModule->cursor)); display->setFont(FONT_MEDIUM); int cellHeight = round((display->height() - 64) / outerSize); int yCorrection = 8; for (int8_t outerIndex = 0; outerIndex < outerSize; outerIndex++) { yOffset += outerIndex > 0 ? cellHeight : 0; int innerSizeBound = *(&this->keyboard[this->charSet][outerIndex] + 1) - this->keyboard[this->charSet][outerIndex]; int innerSize = 0; for (int8_t innerIndex = 0; innerIndex < innerSizeBound; innerIndex++) { if (this->keyboard[this->charSet][outerIndex][innerIndex].character != "") { innerSize++; } } int cellWidth = display->width() / innerSize; for (int8_t innerIndex = 0; innerIndex < innerSize; innerIndex++) { xOffset += innerIndex > 0 ? cellWidth : 0; Letter letter = this->keyboard[this->charSet][outerIndex][innerIndex]; Letter updatedLetter = {letter.character, letter.width, xOffset, yOffset, cellWidth, cellHeight}; this->keyboard[this->charSet][outerIndex][innerIndex] = updatedLetter; float characterOffset = ((cellWidth / 2) - (letter.width / 2)); if (letter.character == "⇧") { if (this->shift) { display->fillRect(xOffset, yOffset, cellWidth, cellHeight); display->setColor(OLEDDISPLAY_COLOR::BLACK); drawShiftIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.2); display->setColor(OLEDDISPLAY_COLOR::WHITE); } else { display->drawRect(xOffset, yOffset, cellWidth, cellHeight); drawShiftIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.2); } } else if (letter.character == "⌫") { if (this->highlight == letter.character[0]) { display->fillRect(xOffset, yOffset, cellWidth, cellHeight); display->setColor(OLEDDISPLAY_COLOR::BLACK); drawBackspaceIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.2); display->setColor(OLEDDISPLAY_COLOR::WHITE); setIntervalFromNow(0); } else { display->drawRect(xOffset, yOffset, cellWidth, cellHeight); drawBackspaceIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.2); } } else if (letter.character == "↵") { display->drawRect(xOffset, yOffset, cellWidth, cellHeight); drawEnterIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.7); } else { if (this->highlight == letter.character[0]) { display->fillRect(xOffset, yOffset, cellWidth, cellHeight); display->setColor(OLEDDISPLAY_COLOR::BLACK); display->drawString(xOffset + characterOffset, yOffset + yCorrection, letter.character == " " ? "space" : letter.character); display->setColor(OLEDDISPLAY_COLOR::WHITE); setIntervalFromNow(0); } else { display->drawRect(xOffset, yOffset, cellWidth, cellHeight); display->drawString(xOffset + characterOffset, yOffset + yCorrection, letter.character == " " ? "space" : letter.character); } } } xOffset = 0; } this->highlight = 0x00; } void CannedMessageModule::drawShiftIcon(OLEDDisplay *display, int x, int y, float scale) { PointStruct shiftIcon[10] = {{8, 0}, {15, 7}, {15, 8}, {12, 8}, {12, 12}, {4, 12}, {4, 8}, {1, 8}, {1, 7}, {8, 0}}; int size = 10; for (int i = 0; i < size - 1; i++) { int x0 = x + (shiftIcon[i].x * scale); int y0 = y + (shiftIcon[i].y * scale); int x1 = x + (shiftIcon[i + 1].x * scale); int y1 = y + (shiftIcon[i + 1].y * scale); display->drawLine(x0, y0, x1, y1); } } void CannedMessageModule::drawBackspaceIcon(OLEDDisplay *display, int x, int y, float scale) { PointStruct backspaceIcon[6] = {{0, 7}, {5, 2}, {15, 2}, {15, 12}, {5, 12}, {0, 7}}; int size = 6; for (int i = 0; i < size - 1; i++) { int x0 = x + (backspaceIcon[i].x * scale); int y0 = y + (backspaceIcon[i].y * scale); int x1 = x + (backspaceIcon[i + 1].x * scale); int y1 = y + (backspaceIcon[i + 1].y * scale); display->drawLine(x0, y0, x1, y1); } PointStruct backspaceIconX[4] = {{7, 4}, {13, 10}, {7, 10}, {13, 4}}; size = 4; for (int i = 0; i < size - 1; i++) { int x0 = x + (backspaceIconX[i].x * scale); int y0 = y + (backspaceIconX[i].y * scale); int x1 = x + (backspaceIconX[i + 1].x * scale); int y1 = y + (backspaceIconX[i + 1].y * scale); display->drawLine(x0, y0, x1, y1); } } void CannedMessageModule::drawEnterIcon(OLEDDisplay *display, int x, int y, float scale) { PointStruct enterIcon[6] = {{0, 7}, {4, 3}, {4, 11}, {0, 7}, {15, 7}, {15, 0}}; int size = 6; for (int i = 0; i < size - 1; i++) { int x0 = x + (enterIcon[i].x * scale); int y0 = y + (enterIcon[i].y * scale); int x1 = x + (enterIcon[i + 1].x * scale); int y1 = y + (enterIcon[i + 1].y * scale); display->drawLine(x0, y0, x1, y1); } } #endif void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { char buffer[50]; if (temporaryMessage.length() != 0) { requestFocus(); // Tell Screen::setFrames to move to our module's frame LOG_DEBUG("Drawing temporary message: %s", temporaryMessage.c_str()); display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_MEDIUM); display->drawString(display->getWidth() / 2 + x, 0 + y + 12, temporaryMessage); } else if (cannedMessageModule->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) { requestFocus(); // Tell Screen::setFrames to move to our module's frame EINK_ADD_FRAMEFLAG(display, COSMETIC); // Clean after this popup. Layout makes ghosting particularly obvious #ifdef USE_EINK display->setFont(FONT_SMALL); // No chunky text #else display->setFont(FONT_MEDIUM); // Chunky text #endif String displayString; display->setTextAlignment(TEXT_ALIGN_CENTER); if (this->ack) { displayString = "Delivered to\n%s"; } else { displayString = "Delivery failed\nto %s"; } display->drawStringf(display->getWidth() / 2 + x, 0 + y + 12, buffer, displayString, cannedMessageModule->getNodeName(this->incoming)); display->setFont(FONT_SMALL); String snrString = "Last Rx SNR: %f"; String rssiString = "Last Rx RSSI: %d"; // Don't bother drawing snr and rssi for tiny displays if (display->getHeight() > 100) { // Original implementation used constants of y = 100 and y = 130. Shrink this if screen is *slightly* small int16_t snrY = 100; int16_t rssiY = 130; // If dislay is *slighly* too small for the original consants, squish up a bit if (display->getHeight() < rssiY) { snrY = display->getHeight() - ((1.5) * FONT_HEIGHT_SMALL); rssiY = display->getHeight() - ((2.5) * FONT_HEIGHT_SMALL); } if (this->ack) { display->drawStringf(display->getWidth() / 2 + x, snrY + y, buffer, snrString, this->lastRxSnr); display->drawStringf(display->getWidth() / 2 + x, rssiY + y, buffer, rssiString, this->lastRxRssi); } } } else if (cannedMessageModule->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) { // E-Ink: clean the screen *after* this pop-up EINK_ADD_FRAMEFLAG(display, COSMETIC); requestFocus(); // Tell Screen::setFrames to move to our module's frame #ifdef USE_EINK display->setFont(FONT_SMALL); // No chunky text #else display->setFont(FONT_MEDIUM); // Chunky text #endif display->setTextAlignment(TEXT_ALIGN_CENTER); display->drawString(display->getWidth() / 2 + x, 0 + y + 12, "Sending..."); } else if (cannedMessageModule->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) { display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); display->drawString(10 + x, 0 + y + FONT_HEIGHT_SMALL, "Canned Message\nModule disabled."); } else if (cannedMessageModule->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) { requestFocus(); // Tell Screen::setFrames to move to our module's frame #if defined(T_WATCH_S3) || defined(RAK14014) drawKeyboard(display, state, 0, 0); #else display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); if (this->destSelect != CANNED_MESSAGE_DESTINATION_TYPE_NONE) { display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); display->setColor(BLACK); } switch (this->destSelect) { case CANNED_MESSAGE_DESTINATION_TYPE_NODE: display->drawStringf(1 + x, 0 + y, buffer, "To: >%s<@%s", cannedMessageModule->getNodeName(this->dest), channels.getName(indexChannels[this->channel])); display->drawStringf(0 + x, 0 + y, buffer, "To: >%s<@%s", cannedMessageModule->getNodeName(this->dest), channels.getName(indexChannels[this->channel])); break; case CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL: display->drawStringf(1 + x, 0 + y, buffer, "To: %s@>%s<", cannedMessageModule->getNodeName(this->dest), channels.getName(indexChannels[this->channel])); display->drawStringf(0 + x, 0 + y, buffer, "To: %s@>%s<", cannedMessageModule->getNodeName(this->dest), channels.getName(indexChannels[this->channel])); break; default: if (display->getWidth() > 128) { display->drawStringf(0 + x, 0 + y, buffer, "To: %s@%s", cannedMessageModule->getNodeName(this->dest), channels.getName(indexChannels[this->channel])); } else { display->drawStringf(0 + x, 0 + y, buffer, "To: %.5s@%.5s", cannedMessageModule->getNodeName(this->dest), channels.getName(indexChannels[this->channel])); } break; } // used chars right aligned, only when not editing the destination if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NONE) { uint16_t charsLeft = meshtastic_Constants_DATA_PAYLOAD_LEN - this->freetext.length() - (moduleConfig.canned_message.send_bell ? 1 : 0); snprintf(buffer, sizeof(buffer), "%d left", charsLeft); display->drawString(x + display->getWidth() - display->getStringWidth(buffer), y + 0, buffer); } display->setColor(WHITE); display->drawStringMaxWidth( 0 + x, 0 + y + FONT_HEIGHT_SMALL, x + display->getWidth(), cannedMessageModule->drawWithCursor(cannedMessageModule->freetext, cannedMessageModule->cursor)); #endif } else { if (this->messagesCount > 0) { display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); display->drawStringf(0 + x, 0 + y, buffer, "To: %s", cannedMessageModule->getNodeName(this->dest)); int lines = (display->getHeight() / FONT_HEIGHT_SMALL) - 1; if (lines == 3) { // static (old) behavior for small displays display->drawString(0 + x, 0 + y + FONT_HEIGHT_SMALL, cannedMessageModule->getPrevMessage()); display->fillRect(0 + x, 0 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(), y + FONT_HEIGHT_SMALL); display->setColor(BLACK); display->drawString(0 + x, 0 + y + FONT_HEIGHT_SMALL * 2, cannedMessageModule->getCurrentMessage()); display->setColor(WHITE); display->drawString(0 + x, 0 + y + FONT_HEIGHT_SMALL * 3, cannedMessageModule->getNextMessage()); } else { // use entire display height for larger displays int topMsg = (messagesCount > lines && currentMessageIndex >= lines - 1) ? currentMessageIndex - lines + 2 : 0; for (int i = 0; i < std::min(messagesCount, lines); i++) { if (i == currentMessageIndex - topMsg) { #ifdef USE_EINK // Avoid drawing solid black with fillRect: harder to clear for E-Ink display->drawString(0 + x, 0 + y + FONT_HEIGHT_SMALL * (i + 1), ">"); display->drawString(12 + x, 0 + y + FONT_HEIGHT_SMALL * (i + 1), cannedMessageModule->getCurrentMessage()); #else display->fillRect(0 + x, 0 + y + FONT_HEIGHT_SMALL * (i + 1), x + display->getWidth(), y + FONT_HEIGHT_SMALL); display->setColor(BLACK); display->drawString(0 + x, 0 + y + FONT_HEIGHT_SMALL * (i + 1), cannedMessageModule->getCurrentMessage()); display->setColor(WHITE); #endif } else { display->drawString(0 + x, 0 + y + FONT_HEIGHT_SMALL * (i + 1), cannedMessageModule->getMessageByIndex(topMsg + i)); } } } } } } ProcessMessage CannedMessageModule::handleReceived(const meshtastic_MeshPacket &mp) { if (mp.decoded.portnum == meshtastic_PortNum_ROUTING_APP && waitingForAck) { // look for a request_id if (mp.decoded.request_id != 0) { UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen requestFocus(); // Tell Screen::setFrames that our module's frame should be shown, even if not "first" in the frameset this->runState = CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED; this->incoming = service->getNodenumFromRequestId(mp.decoded.request_id); meshtastic_Routing decoded = meshtastic_Routing_init_default; pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, meshtastic_Routing_fields, &decoded); this->ack = decoded.error_reason == meshtastic_Routing_Error_NONE; waitingForAck = false; // No longer want routing packets this->notifyObservers(&e); // run the next time 2 seconds later setIntervalFromNow(2000); } } return ProcessMessage::CONTINUE; } void CannedMessageModule::loadProtoForModule() { if (nodeDB->loadProto(cannedMessagesConfigFile, meshtastic_CannedMessageModuleConfig_size, sizeof(meshtastic_CannedMessageModuleConfig), &meshtastic_CannedMessageModuleConfig_msg, &cannedMessageModuleConfig) != LoadFileResult::LOAD_SUCCESS) { installDefaultCannedMessageModuleConfig(); } } /** * @brief Save the module config to file. * * @return true On success. * @return false On error. */ bool CannedMessageModule::saveProtoForModule() { bool okay = true; #ifdef FS FS.mkdir("/prefs"); #endif okay &= nodeDB->saveProto(cannedMessagesConfigFile, meshtastic_CannedMessageModuleConfig_size, &meshtastic_CannedMessageModuleConfig_msg, &cannedMessageModuleConfig); return okay; } /** * @brief Fill configuration with default values. */ void CannedMessageModule::installDefaultCannedMessageModuleConfig() { memset(cannedMessageModuleConfig.messages, 0, sizeof(cannedMessageModuleConfig.messages)); } /** * @brief An admin message arrived to AdminModule. We are asked whether we want to handle that. * * @param mp The mesh packet arrived. * @param request The AdminMessage request extracted from the packet. * @param response The prepared response * @return AdminMessageHandleResult HANDLED if message was handled * HANDLED_WITH_RESULT if a result is also prepared. */ AdminMessageHandleResult CannedMessageModule::handleAdminMessageForModule(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, meshtastic_AdminMessage *response) { AdminMessageHandleResult result; switch (request->which_payload_variant) { case meshtastic_AdminMessage_get_canned_message_module_messages_request_tag: LOG_DEBUG("Client is getting radio canned messages\n"); this->handleGetCannedMessageModuleMessages(mp, response); result = AdminMessageHandleResult::HANDLED_WITH_RESPONSE; break; case meshtastic_AdminMessage_set_canned_message_module_messages_tag: LOG_DEBUG("Client is setting radio canned messages\n"); this->handleSetCannedMessageModuleMessages(request->set_canned_message_module_messages); result = AdminMessageHandleResult::HANDLED; break; default: result = AdminMessageHandleResult::NOT_HANDLED; } return result; } void CannedMessageModule::handleGetCannedMessageModuleMessages(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response) { LOG_DEBUG("*** handleGetCannedMessageModuleMessages\n"); if (req.decoded.want_response) { response->which_payload_variant = meshtastic_AdminMessage_get_canned_message_module_messages_response_tag; strncpy(response->get_canned_message_module_messages_response, cannedMessageModuleConfig.messages, sizeof(response->get_canned_message_module_messages_response)); } // Don't send anything if not instructed to. Better than asserting. } void CannedMessageModule::handleSetCannedMessageModuleMessages(const char *from_msg) { int changed = 0; if (*from_msg) { changed |= strcmp(cannedMessageModuleConfig.messages, from_msg); strncpy(cannedMessageModuleConfig.messages, from_msg, sizeof(cannedMessageModuleConfig.messages)); LOG_DEBUG("*** from_msg.text:%s\n", from_msg); } if (changed) { this->saveProtoForModule(); } } String CannedMessageModule::drawWithCursor(String text, int cursor) { String result = text.substring(0, cursor) + "_" + text.substring(cursor); return result; } #endif