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