diff --git a/src/graphics/EInkDynamicDisplay.cpp b/src/graphics/EInkDynamicDisplay.cpp
index 5b97b8d48d..c31941a603 100644
--- a/src/graphics/EInkDynamicDisplay.cpp
+++ b/src/graphics/EInkDynamicDisplay.cpp
@@ -375,7 +375,7 @@ void EInkDynamicDisplay::hashImage()
// Sum all bytes of the image buffer together
for (uint16_t b = 0; b < (displayWidth / 8) * displayHeight; b++) {
- imageHash += buffer[b];
+ imageHash ^= buffer[b] << b;
}
}
diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index fe6fd3f06c..ea5ab97885 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -37,6 +37,7 @@ along with this program. If not, see .
#include "gps/RTC.h"
#include "graphics/ScreenFonts.h"
#include "graphics/images.h"
+#include "input/ScanAndSelect.h"
#include "input/TouchScreenImpl1.h"
#include "main.h"
#include "mesh-pb-constants.h"
@@ -2291,6 +2292,11 @@ void Screen::handlePrint(const char *text)
void Screen::handleOnPress()
{
+ // If Canned Messages is using the "Scan and Select" input, dismiss the canned message frame when user button is pressed
+ // Minimize impact as a courtesy, as "scan and select" may be used as default config for some boards
+ if (scanAndSelectInput != nullptr && scanAndSelectInput->dismissCannedMessageFrame())
+ return;
+
// If screen was off, just wake it, otherwise advance to next frame
// If we are in a transition, the press must have bounced, drop it.
if (ui->getUiState()->frameState == FIXED) {
diff --git a/src/input/ScanAndSelect.cpp b/src/input/ScanAndSelect.cpp
new file mode 100644
index 0000000000..d693d768cb
--- /dev/null
+++ b/src/input/ScanAndSelect.cpp
@@ -0,0 +1,204 @@
+#include "configuration.h"
+
+// Normally these input methods are protected by guarding in setupModules
+// In order to have the user button dismiss the canned message frame, this class lightly interacts with the Screen class
+#if HAS_SCREEN
+
+#include "ScanAndSelect.h"
+#include "modules/CannedMessageModule.h"
+
+// Config
+static const char name[] = "scanAndSelect"; // should match "allow input source" string
+static constexpr uint32_t durationShortMs = 50;
+static constexpr uint32_t durationLongMs = 1500;
+static constexpr uint32_t durationAlertMs = 2000;
+
+// Constructor: init base class
+ScanAndSelectInput::ScanAndSelectInput() : concurrency::OSThread(name) {}
+
+// Attempt to setup class; true if success.
+// Called by setupModules method. Instance deleted if setup fails.
+bool ScanAndSelectInput::init()
+{
+ // Short circuit: Canned messages enabled?
+ if (!moduleConfig.canned_message.enabled)
+ return false;
+
+ // Short circuit: Using correct "input source"?
+ // Todo: protobuf enum instead of string?
+ if (strcasecmp(moduleConfig.canned_message.allow_input_source, name) != 0)
+ return false;
+
+ // Use any available inputbroker pin as the button
+ if (moduleConfig.canned_message.inputbroker_pin_press)
+ pin = moduleConfig.canned_message.inputbroker_pin_press;
+ else if (moduleConfig.canned_message.inputbroker_pin_a)
+ pin = moduleConfig.canned_message.inputbroker_pin_a;
+ else if (moduleConfig.canned_message.inputbroker_pin_b)
+ pin = moduleConfig.canned_message.inputbroker_pin_b;
+ else
+ return false; // Short circuit: no button found
+
+ // Set-up the button
+ pinMode(pin, INPUT_PULLUP);
+ attachInterrupt(pin, handleChangeInterrupt, CHANGE);
+
+ // Connect our class to the canned message module
+ inputBroker->registerSource(this);
+
+ LOG_INFO("Initialized 'Scan and Select' input for Canned Messages, using pin %d\n", pin);
+ return true; // Init succeded
+}
+
+// Runs periodically, unless sleeping between presses
+int32_t ScanAndSelectInput::runOnce()
+{
+ uint32_t now = millis();
+
+ // If: "no messages added" alert screen currently shown
+ if (alertingNoMessage) {
+ // Dismiss the alert screen several seconds after it appears
+ if (now > alertingSinceMs + durationAlertMs) {
+ alertingNoMessage = false;
+ screen->endAlert();
+ }
+ }
+
+ // If: Button is pressed
+ if (digitalRead(pin) == LOW) {
+ // New press
+ if (!held) {
+ downSinceMs = now;
+ }
+
+ // Existing press
+ else {
+ // Duration enough for long press
+ // Long press not yet fired (prevent repeat firing while held)
+ if (!longPressFired && now - downSinceMs > durationLongMs) {
+ longPressFired = true;
+ longPress();
+ }
+ }
+
+ // Record the change of state: button is down
+ held = true;
+ }
+
+ // If: Button is not pressed
+ else {
+ // Button newly released
+ // Long press event didn't already fire
+ if (held && !longPressFired) {
+ // Duration enough for short press
+ if (now - downSinceMs > durationShortMs) {
+ shortPress();
+ }
+ }
+
+ // Record the change of state: button is up
+ held = false;
+ longPressFired = false; // Re-Arm: allow another long press
+ }
+
+ // If thread's job is done, let it sleep
+ if (!held && !alertingNoMessage) {
+ Thread::canSleep = true;
+ return OSThread::disable();
+ }
+
+ // Run this method again is a few ms
+ return durationShortMs;
+}
+
+void ScanAndSelectInput::longPress()
+{
+ // (If canned messages set)
+ if (cannedMessageModule->hasMessages()) {
+ // If module frame displayed already, send the current message
+ if (cannedMessageModule->shouldDraw())
+ raiseEvent(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT);
+
+ // Otherwise, initial long press opens the module frame
+ else
+ raiseEvent(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN);
+ }
+
+ // (If canned messages not set) tell the user
+ else
+ alertNoMessage();
+}
+
+void ScanAndSelectInput::shortPress()
+{
+ // (If canned messages set) scroll to next message
+ if (cannedMessageModule->hasMessages())
+ raiseEvent(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN);
+
+ // (If canned messages not yet set) tell the user
+ else
+ alertNoMessage();
+}
+
+// Begin running runOnce at regular intervals
+// Called from pin change interrupt
+void ScanAndSelectInput::enableThread()
+{
+ Thread::canSleep = false;
+ OSThread::enabled = true;
+ OSThread::setIntervalFromNow(0);
+}
+
+// Inform user (screen) that no canned messages have been added
+// Automatically dismissed after several seconds
+void ScanAndSelectInput::alertNoMessage()
+{
+ alertingNoMessage = true;
+ alertingSinceMs = millis();
+
+ // Graphics code: the alert frame to show on screen
+ screen->startAlert([](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void {
+ display->setTextAlignment(TEXT_ALIGN_CENTER_BOTH);
+ display->setFont(FONT_SMALL);
+ int16_t textX = display->getWidth() / 2;
+ int16_t textY = display->getHeight() / 2;
+ display->drawString(textX + x, textY + y, "No Canned Messages");
+ });
+}
+
+// Remove the canned message frame from screen
+// Used to dismiss the module frame when user button pressed
+// Returns true if the frame was previously displayed, and has now been closed
+// Return value consumed by Screen class when determining how to handle user button
+bool ScanAndSelectInput::dismissCannedMessageFrame()
+{
+ if (cannedMessageModule->shouldDraw()) {
+ raiseEvent(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL);
+ return true;
+ }
+
+ return false;
+}
+
+// Feed input to the canned messages module
+void ScanAndSelectInput::raiseEvent(_meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar key)
+{
+ InputEvent e;
+ e.source = name;
+ e.inputEvent = key;
+ notifyObservers(&e);
+}
+
+// Pin change interrupt
+void ScanAndSelectInput::handleChangeInterrupt()
+{
+ // Because we need to detect both press and release (rising and falling edge), the interrupt itself can't determine the
+ // action. Instead, we start up the thread and get it to read the button for us
+
+ // The instance we're referring to here is created in setupModules()
+ scanAndSelectInput->enableThread();
+}
+
+ScanAndSelectInput *scanAndSelectInput = nullptr; // Instantiated in setupModules method. Deleted if unused, or init() fails
+
+#endif
\ No newline at end of file
diff --git a/src/input/ScanAndSelect.h b/src/input/ScanAndSelect.h
new file mode 100644
index 0000000000..0b3e2716e4
--- /dev/null
+++ b/src/input/ScanAndSelect.h
@@ -0,0 +1,50 @@
+/*
+ A "single button" input method for Canned Messages
+
+ - Short press to cycle through messages
+ - Long Press to send
+
+ To use:
+ - set "allow input source" to "scanAndSelect"
+ - set the single button's GPIO as either pin A, pin B, or pin Press
+
+ Originally designed to make use of "extra" built-in button on some boards.
+ Non-intrusive; suitable for use as a default module config.
+*/
+
+#pragma once
+#include "concurrency/OSThread.h"
+#include "main.h"
+
+// Normally these input methods are protected by guarding in setupModules
+// In order to have the user button dismiss the canned message frame, this class lightly interacts with the Screen class
+#if HAS_SCREEN
+
+class ScanAndSelectInput : public Observable, public concurrency::OSThread
+{
+ public:
+ ScanAndSelectInput(); // No-op constructor, only initializes OSThread base class
+ bool init(); // Attempt to setup class; true if success. Instance deleted if setup fails
+ bool dismissCannedMessageFrame(); // Remove the canned message frame from screen. True if frame was open, and now closed.
+ void alertNoMessage(); // Inform user (screen) that no canned messages have been added
+
+ protected:
+ int32_t runOnce() override; // Runs at regular intervals, when enabled
+ void enableThread(); // Begin running runOnce at regular intervals
+ static void handleChangeInterrupt(); // Calls enableThread from pin change interrupt
+ void shortPress(); // Code to run when short press fires
+ void longPress(); // Code to run when long press fires
+ void raiseEvent(_meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar key); // Feed input to canned message module
+
+ bool held = false; // Have we handled a change in button state?
+ bool longPressFired = false; // Long press fires while button still held. This bool ensures the release is no-op
+ uint32_t downSinceMs = 0; // Debouncing for short press, timing for long press
+ uint8_t pin = -1; // Read from cannned message config during init
+
+ bool alertingNoMessage = false; // Is the "no canned messages" alert shown on screen?
+ uint32_t alertingSinceMs = 0; // Used to dismiss the "no canned message" alert several seconds
+};
+
+extern ScanAndSelectInput *scanAndSelectInput; // Instantiated in setupModules method. Deleted if unused, or init() fails
+
+#endif
\ No newline at end of file
diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp
index 61f08fe653..d74e96bc63 100644
--- a/src/mesh/NodeDB.cpp
+++ b/src/mesh/NodeDB.cpp
@@ -394,6 +394,13 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.external_notification.output_ms = 100;
moduleConfig.external_notification.active = true;
#endif
+#ifdef BUTTON_SECONDARY_CANNEDMESSAGES
+ // Use a board's second built-in button as input source for canned messages
+ moduleConfig.canned_message.enabled = true;
+ moduleConfig.canned_message.inputbroker_pin_press = BUTTON_PIN_SECONDARY;
+ strcpy(moduleConfig.canned_message.allow_input_source, "scanAndSelect");
+#endif
+
moduleConfig.has_canned_message = true;
strncpy(moduleConfig.mqtt.address, default_mqtt_address, sizeof(moduleConfig.mqtt.address));
diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp
index f4ee3abd20..4df5a03fc3 100644
--- a/src/modules/CannedMessageModule.cpp
+++ b/src/modules/CannedMessageModule.cpp
@@ -10,6 +10,7 @@
#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
@@ -694,9 +695,22 @@ 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)) {
@@ -931,13 +945,17 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
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) {
- // 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
+ 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
- requestFocus(); // Tell Screen::setFrames to move to our module's frame
- display->setTextAlignment(TEXT_ALIGN_CENTER);
- display->setFont(FONT_MEDIUM);
String displayString;
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
if (this->ack) {
displayString = "Delivered to\n%s";
} else {
@@ -951,17 +969,37 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
String snrString = "Last Rx SNR: %f";
String rssiString = "Last Rx RSSI: %d";
- if (this->ack) {
- display->drawStringf(display->getWidth() / 2 + x, y + 100, buffer, snrString, this->lastRxSnr);
- display->drawStringf(display->getWidth() / 2 + x, y + 130, buffer, rssiString, this->lastRxRssi);
+ // 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->setFont(FONT_MEDIUM);
display->drawString(display->getWidth() / 2 + x, 0 + y + 12, "Sending...");
} else if (cannedMessageModule->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) {
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -1033,11 +1071,18 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
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));
diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h
index 797b9f7cff..9e6af8890f 100644
--- a/src/modules/CannedMessageModule.h
+++ b/src/modules/CannedMessageModule.h
@@ -56,6 +56,7 @@ class CannedMessageModule : public SinglePortModule, public Observableinit()) {
+ delete scanAndSelectInput;
+ scanAndSelectInput = nullptr;
+ }
+#endif
+
cardKbI2cImpl = new CardKbI2cImpl();
cardKbI2cImpl->init();
#ifdef INPUTBROKER_MATRIX_TYPE
diff --git a/variants/heltec_vision_master_e213/variant.h b/variants/heltec_vision_master_e213/variant.h
index bbc697f09b..0771b35173 100644
--- a/variants/heltec_vision_master_e213/variant.h
+++ b/variants/heltec_vision_master_e213/variant.h
@@ -1,4 +1,6 @@
#define BUTTON_PIN 0
+#define BUTTON_PIN_SECONDARY 21 // Second built-in button
+#define BUTTON_SECONDARY_CANNEDMESSAGES // By default, use the secondary button as canned message input
// I2C
#define I2C_SDA SDA
diff --git a/variants/heltec_vision_master_e290/variant.h b/variants/heltec_vision_master_e290/variant.h
index 6af4b06a58..72a82cfdb8 100644
--- a/variants/heltec_vision_master_e290/variant.h
+++ b/variants/heltec_vision_master_e290/variant.h
@@ -1,4 +1,6 @@
#define BUTTON_PIN 0
+#define BUTTON_PIN_SECONDARY 21 // Second built-in button
+#define BUTTON_SECONDARY_CANNEDMESSAGES // By default, use the secondary button as canned message input
// I2C
#define I2C_SDA SDA