diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index 55e8648d9..b40fb85a5 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -96,9 +96,9 @@ Lora: ### Some devices, like the pinedio, may require spidev0.1 as a workaround. # spidev: spidev0.0 -### Define GPIO buttons here: +### Deprecated location for User Button: -GPIO: +#GPIO: # User: 6 ### Define GPS @@ -115,17 +115,6 @@ I2C: Display: -### Waveshare 1.44inch LCD HAT -# Panel: ST7735S -# CS: 8 #Chip Select -# DC: 25 # Data/Command pin -# Backlight: 24 -# Width: 128 -# Height: 128 -# Reset: 27 -# OffsetX: 0 -# OffsetY: 0 - ### Adafruit PiTFT 2.8 TFT+Touchscreen # Panel: ILI9341 # CS: 8 @@ -180,6 +169,16 @@ Input: # KeyboardDevice: /dev/input/by-id/usb-_Raspberry_Pi_Internal_Keyboard-event-kbd +### Standard User Button Config +# UserButton: 6 + +### Trackball/Joystick input +# TrackballUp: 6 +# TrackballDown: 19 +# TrackballLeft: 5 +# TrackballRight: 26 +# TrackballPress: 13 + ### Logging: diff --git a/bin/config.d/display-waveshare-1-44.yaml b/bin/config.d/display-waveshare-1-44.yaml new file mode 100644 index 000000000..1d85a4a3b --- /dev/null +++ b/bin/config.d/display-waveshare-1-44.yaml @@ -0,0 +1,26 @@ +### Waveshare 1.44inch LCD HAT +Display: + Panel: ST7735S + spidev: spidev0.0 # Specify either the spidev here, or the CS below +# CS: 8 #Chip Select # Optional, as this is the default pin for spidev0.0 + DC: 25 # Data/Command pin + Backlight: 24 + Width: 128 + Height: 128 + Reset: 27 + OffsetX: 2 + OffsetY: 1 + + +# OffsetY: 31 # These two options are used to properly flip the screen 180 degrees +# OffsetRotate: 3 + + +Input: + TrackballUp: 6 + TrackballDown: 19 + TrackballLeft: 5 + TrackballRight: 26 + TrackballPress: 13 + +# User: 21 diff --git a/platformio.ini b/platformio.ini index 2350716cc..debc77a92 100644 --- a/platformio.ini +++ b/platformio.ini @@ -165,6 +165,8 @@ lib_deps = adafruit/Adafruit LTR390 Library@1.1.2 # renovate: datasource=custom.pio depName=Adafruit PCT2075 packageName=adafruit/library/Adafruit PCT2075 adafruit/Adafruit PCT2075@1.0.5 + # renovate: datasource=custom.pio depName=DFRobot_BMM150 packageName=dfrobot/library/DFRobot_BMM150 + dfrobot/DFRobot_BMM150@1.0.0 ; (not included in native / portduino) [environmental_extra] diff --git a/src/AudioThread.h b/src/AudioThread.h index 04ff64a6e..286729909 100644 --- a/src/AudioThread.h +++ b/src/AudioThread.h @@ -47,6 +47,20 @@ class AudioThread : public concurrency::OSThread setCPUFast(false); } + void readAloud(const char *text) + { + if (i2sRtttl != nullptr) { + i2sRtttl->stop(); + delete i2sRtttl; + i2sRtttl = nullptr; + } + + ESP8266SAM *sam = new ESP8266SAM; + sam->Say(audioOut, text); + delete sam; + setCPUFast(false); + } + protected: int32_t runOnce() override { diff --git a/src/ButtonThread.cpp b/src/ButtonThread.cpp deleted file mode 100644 index 8db52c074..000000000 --- a/src/ButtonThread.cpp +++ /dev/null @@ -1,467 +0,0 @@ -#include "ButtonThread.h" - -#include "configuration.h" -#if !MESHTASTIC_EXCLUDE_GPS -#include "GPS.h" -#endif -#include "MeshService.h" -#include "PowerFSM.h" -#include "RadioLibInterface.h" -#include "buzz.h" -#include "main.h" -#include "modules/ExternalNotificationModule.h" -#include "power.h" -#include "sleep.h" -#ifdef ARCH_PORTDUINO -#include "platform/portduino/PortduinoGlue.h" -#endif - -#define DEBUG_BUTTONS 0 -#if DEBUG_BUTTONS -#define LOG_BUTTON(...) LOG_DEBUG(__VA_ARGS__) -#else -#define LOG_BUTTON(...) -#endif - -using namespace concurrency; - -ButtonThread *buttonThread; // Declared extern in header -volatile ButtonThread::ButtonEventType ButtonThread::btnEvent = ButtonThread::BUTTON_EVENT_NONE; - -#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(USERPREFS_BUTTON_PIN) -OneButton ButtonThread::userButton; // Get reference to static member -#endif -ButtonThread::ButtonThread() : OSThread("Button") -{ -#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(USERPREFS_BUTTON_PIN) - -#if defined(ARCH_PORTDUINO) - if (settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC) { - this->userButton = OneButton(settingsMap[user], true, true); - LOG_DEBUG("Use GPIO%02d for button", settingsMap[user]); - } -#elif defined(BUTTON_PIN) -#if !defined(USERPREFS_BUTTON_PIN) - int pin = config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN; // Resolved button pin -#endif -#ifdef USERPREFS_BUTTON_PIN - int pin = config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN; // Resolved button pin -#endif -#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB) - this->userButton = OneButton(pin, false, false); -#elif defined(BUTTON_ACTIVE_LOW) - this->userButton = OneButton(pin, BUTTON_ACTIVE_LOW, BUTTON_ACTIVE_PULLUP); -#else - this->userButton = OneButton(pin, true, true); -#endif - LOG_DEBUG("Use GPIO%02d for button", pin); -#endif - -#ifdef INPUT_PULLUP_SENSE - // Some platforms (nrf52) have a SENSE variant which allows wake from sleep - override what OneButton did -#ifdef BUTTON_SENSE_TYPE - pinMode(pin, BUTTON_SENSE_TYPE); -#else - pinMode(pin, INPUT_PULLUP_SENSE); -#endif -#endif - -#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(USERPREFS_BUTTON_PIN) - userButton.attachClick(userButtonPressed); - userButton.setClickMs(BUTTON_CLICK_MS); - userButton.setPressMs(BUTTON_LONGPRESS_MS); - userButton.setDebounceMs(1); - userButton.attachDoubleClick(userButtonDoublePressed); - userButton.attachMultiClick(userButtonMultiPressed, this); // Reference to instance: get click count from non-static OneButton -#if !defined(T_DECK) && \ - !defined( \ - ELECROW_ThinkNode_M2) // T-Deck immediately wakes up after shutdown, Thinknode M2 has this on the smaller ALT button - userButton.attachLongPressStart(userButtonPressedLongStart); - userButton.attachLongPressStop(userButtonPressedLongStop); -#endif -#endif - -#ifdef BUTTON_PIN_ALT -#if defined(ELECROW_ThinkNode_M2) - this->userButtonAlt = OneButton(BUTTON_PIN_ALT, false, false); -#else - this->userButtonAlt = OneButton(BUTTON_PIN_ALT, true, true); -#endif -#ifdef INPUT_PULLUP_SENSE - // Some platforms (nrf52) have a SENSE variant which allows wake from sleep - override what OneButton did - pinMode(BUTTON_PIN_ALT, INPUT_PULLUP_SENSE); -#endif - userButtonAlt.attachClick(userButtonPressedScreen); - userButtonAlt.setClickMs(BUTTON_CLICK_MS); - userButtonAlt.setPressMs(BUTTON_LONGPRESS_MS); - userButtonAlt.setDebounceMs(1); - userButtonAlt.attachLongPressStart(userButtonPressedLongStart); - userButtonAlt.attachLongPressStop(userButtonPressedLongStop); -#endif - -#ifdef BUTTON_PIN_TOUCH - userButtonTouch = OneButton(BUTTON_PIN_TOUCH, true, true); - userButtonTouch.setPressMs(BUTTON_TOUCH_MS); - userButtonTouch.attachLongPressStart(touchPressedLongStart); // Better handling with longpress than click? -#endif - -#ifdef ARCH_ESP32 - // Register callbacks for before and after lightsleep - // Used to detach and reattach interrupts - lsObserver.observe(¬ifyLightSleep); - lsEndObserver.observe(¬ifyLightSleepEnd); -#endif - - attachButtonInterrupts(); -#endif -} - -void ButtonThread::switchPage() -{ -#ifdef BUTTON_PIN -#if !defined(USERPREFS_BUTTON_PIN) - if (((config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN) != - moduleConfig.canned_message.inputbroker_pin_press) || - !(moduleConfig.canned_message.updown1_enabled || moduleConfig.canned_message.rotary1_enabled) || - !moduleConfig.canned_message.enabled) { - powerFSM.trigger(EVENT_PRESS); - } -#endif -#if defined(USERPREFS_BUTTON_PIN) - if (((config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN) != - moduleConfig.canned_message.inputbroker_pin_press) || - !(moduleConfig.canned_message.updown1_enabled || moduleConfig.canned_message.rotary1_enabled) || - !moduleConfig.canned_message.enabled) { - powerFSM.trigger(EVENT_PRESS); - } -#endif - -#endif -#if defined(ARCH_PORTDUINO) - if ((settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC) && - (settingsMap[user] != moduleConfig.canned_message.inputbroker_pin_press) || - !moduleConfig.canned_message.enabled) { - powerFSM.trigger(EVENT_PRESS); - } -#endif -} - -void ButtonThread::sendAdHocPosition() -{ - service->refreshLocalMeshNode(); - auto sentPosition = service->trySendPosition(NODENUM_BROADCAST, true); - if (screen) { - if (sentPosition) - screen->print("Sent ad-hoc position\n"); - else - screen->print("Sent ad-hoc nodeinfo\n"); - screen->forceDisplay(true); // Force a new UI frame, then force an EInk update - } -} - -int32_t ButtonThread::runOnce() -{ - // If the button is pressed we suppress CPU sleep until release - canSleep = true; // Assume we should not keep the board awake - -#if defined(BUTTON_PIN) || defined(USERPREFS_BUTTON_PIN) - userButton.tick(); - canSleep &= userButton.isIdle(); -#elif defined(ARCH_PORTDUINO) - if (settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC) { - userButton.tick(); - canSleep &= userButton.isIdle(); - } -#endif -#ifdef BUTTON_PIN_ALT - userButtonAlt.tick(); - canSleep &= userButtonAlt.isIdle(); -#endif -#ifdef BUTTON_PIN_TOUCH - userButtonTouch.tick(); - canSleep &= userButtonTouch.isIdle(); -#endif - - if (btnEvent != BUTTON_EVENT_NONE) { - switch (btnEvent) { - case BUTTON_EVENT_PRESSED: { - LOG_BUTTON("press!"); - // If a nag notification is running, stop it and prevent other actions - if (moduleConfig.external_notification.enabled && (externalNotificationModule->nagCycleCutoff != UINT32_MAX)) { - externalNotificationModule->stopNow(); - break; - } -#ifdef ELECROW_ThinkNode_M1 - sendAdHocPosition(); - break; -#endif - switchPage(); - break; - } - - case BUTTON_EVENT_PRESSED_SCREEN: { - LOG_BUTTON("AltPress!"); -#ifdef ELECROW_ThinkNode_M1 - // If a nag notification is running, stop it and prevent other actions - if (moduleConfig.external_notification.enabled && (externalNotificationModule->nagCycleCutoff != UINT32_MAX)) { - externalNotificationModule->stopNow(); - break; - } - switchPage(); - break; -#endif - // turn screen on or off - screen_flag = !screen_flag; - if (screen) - screen->setOn(screen_flag); - break; - } - - case BUTTON_EVENT_DOUBLE_PRESSED: { - LOG_BUTTON("Double press!"); -#ifdef ELECROW_ThinkNode_M1 - digitalWrite(PIN_EINK_EN, digitalRead(PIN_EINK_EN) == LOW); - break; -#endif - sendAdHocPosition(); - break; - } - - case BUTTON_EVENT_MULTI_PRESSED: { - LOG_BUTTON("Mulitipress! %hux", multipressClickCount); - switch (multipressClickCount) { -#if HAS_GPS && !defined(ELECROW_ThinkNode_M1) - // 3 clicks: toggle GPS - case 3: - if (!config.device.disable_triple_click && (gps != nullptr)) { - gps->toggleGpsMode(); - if (screen) - screen->forceDisplay(true); // Force a new UI frame, then force an EInk update - } - break; -#elif defined(ELECROW_ThinkNode_M1) || defined(ELECROW_ThinkNode_M2) - case 3: - LOG_INFO("3 clicks: toggle buzzer"); - buzzer_flag = !buzzer_flag; - if (!buzzer_flag) - noTone(PIN_BUZZER); - break; - -#endif - -#if defined(USE_EINK) && defined(PIN_EINK_EN) && !defined(ELECROW_ThinkNode_M1) // i.e. T-Echo - // 4 clicks: toggle backlight - case 4: - digitalWrite(PIN_EINK_EN, digitalRead(PIN_EINK_EN) == LOW); - break; -#endif -#if !MESHTASTIC_EXCLUDE_SCREEN && HAS_SCREEN - // 5 clicks: start accelerometer/magenetometer calibration for 30 seconds - case 5: - if (accelerometerThread) { - accelerometerThread->calibrate(30); - } - break; - // 6 clicks: start accelerometer/magenetometer calibration for 60 seconds - case 6: - if (accelerometerThread) { - accelerometerThread->calibrate(60); - } - break; -#endif - // No valid multipress action - default: - break; - } // end switch: click count - - break; - } // end multipress event - - case BUTTON_EVENT_LONG_PRESSED: { - LOG_BUTTON("Long press!"); - powerFSM.trigger(EVENT_PRESS); - if (screen) { - screen->startAlert("Shutting down..."); - } - playBeep(); - break; - } - - // Do actual shutdown when button released, otherwise the button release - // may wake the board immediatedly. - case BUTTON_EVENT_LONG_RELEASED: { - LOG_INFO("Shutdown from long press"); - playShutdownMelody(); - delay(3000); - power->shutdown(); - break; - } - -#ifdef BUTTON_PIN_TOUCH - case BUTTON_EVENT_TOUCH_LONG_PRESSED: { - LOG_BUTTON("Touch press!"); - // Ignore if: no screen - if (!screen) - break; - -#ifdef TTGO_T_ECHO - // Ignore if: TX in progress - // Uncommon T-Echo hardware bug, LoRa TX triggers touch button - if (!RadioLibInterface::instance || RadioLibInterface::instance->isSending()) - break; -#endif - - // Wake if asleep - if (powerFSM.getState() == &stateDARK) - powerFSM.trigger(EVENT_PRESS); - - // Update display (legacy behaviour) - screen->forceDisplay(); - break; - } -#endif // BUTTON_PIN_TOUCH - - default: - break; - } - btnEvent = BUTTON_EVENT_NONE; - } - - return 50; -} - -/* - * Attach (or re-attach) hardware interrupts for buttons - * Public method. Used outside class when waking from MCU sleep - */ -void ButtonThread::attachButtonInterrupts() -{ -#if defined(ARCH_PORTDUINO) - if (settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC) - wakeOnIrq(settingsMap[user], FALLING); -#elif defined(BUTTON_PIN) - // Interrupt for user button, during normal use. Improves responsiveness. - attachInterrupt( -#if !defined(USERPREFS_BUTTON_PIN) - config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN, -#endif -#if defined(USERPREFS_BUTTON_PIN) - config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN, -#endif - []() { - ButtonThread::userButton.tick(); - runASAP = true; - BaseType_t higherWake = 0; - mainDelay.interruptFromISR(&higherWake); - }, - CHANGE); -#endif - -#ifdef BUTTON_PIN_ALT -#ifdef ELECROW_ThinkNode_M2 - wakeOnIrq(BUTTON_PIN_ALT, RISING); -#else - wakeOnIrq(BUTTON_PIN_ALT, FALLING); -#endif -#endif - -#ifdef BUTTON_PIN_TOUCH - wakeOnIrq(BUTTON_PIN_TOUCH, FALLING); -#endif -} - -/* - * Detach the "normal" button interrupts. - * Public method. Used before attaching a "wake-on-button" interrupt for MCU sleep - */ -void ButtonThread::detachButtonInterrupts() -{ -#if defined(ARCH_PORTDUINO) - if (settingsMap.count(user) != 0 && settingsMap[user] != RADIOLIB_NC) - detachInterrupt(settingsMap[user]); -#elif defined(BUTTON_PIN) -#if !defined(USERPREFS_BUTTON_PIN) - detachInterrupt(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN); -#endif -#if defined(USERPREFS_BUTTON_PIN) - detachInterrupt(config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN); -#endif -#endif - -#ifdef BUTTON_PIN_ALT - detachInterrupt(BUTTON_PIN_ALT); -#endif - -#ifdef BUTTON_PIN_TOUCH - detachInterrupt(BUTTON_PIN_TOUCH); -#endif -} - -#ifdef ARCH_ESP32 - -// Detach our class' interrupts before lightsleep -// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press -int ButtonThread::beforeLightSleep(void *unused) -{ - detachButtonInterrupts(); - return 0; // Indicates success -} - -// Reconfigure our interrupts -// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep -int ButtonThread::afterLightSleep(esp_sleep_wakeup_cause_t cause) -{ - attachButtonInterrupts(); - return 0; // Indicates success -} - -#endif - -/** - * Watch a GPIO and if we get an IRQ, wake the main thread. - * Use to add wake on button press - */ -void ButtonThread::wakeOnIrq(int irq, int mode) -{ - attachInterrupt( - irq, - [] { - BaseType_t higherWake = 0; - mainDelay.interruptFromISR(&higherWake); - runASAP = true; - }, - FALLING); -} - -// Static callback -void ButtonThread::userButtonMultiPressed(void *callerThread) -{ - // Grab click count from non-static button, while the info is still valid - ButtonThread *thread = (ButtonThread *)callerThread; - thread->storeClickCount(); - - // Then handle later, in the usual way - btnEvent = BUTTON_EVENT_MULTI_PRESSED; -} - -// Non-static method, runs during callback. Grabs info while still valid -void ButtonThread::storeClickCount() -{ -#if defined(BUTTON_PIN) || defined(USERPREFS_BUTTON_PIN) - multipressClickCount = userButton.getNumberClicks(); -#endif -} - -void ButtonThread::userButtonPressedLongStart() -{ - if (millis() > c_holdOffTime) { - btnEvent = BUTTON_EVENT_LONG_PRESSED; - } -} - -void ButtonThread::userButtonPressedLongStop() -{ - if (millis() > c_holdOffTime) { - btnEvent = BUTTON_EVENT_LONG_RELEASED; - } -} \ No newline at end of file diff --git a/src/ButtonThread.h b/src/ButtonThread.h deleted file mode 100644 index 3af700dd0..000000000 --- a/src/ButtonThread.h +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include "OneButton.h" -#include "concurrency/OSThread.h" -#include "configuration.h" - -#ifndef BUTTON_CLICK_MS -#define BUTTON_CLICK_MS 250 -#endif - -#ifndef BUTTON_LONGPRESS_MS -#define BUTTON_LONGPRESS_MS 5000 -#endif - -#ifndef BUTTON_TOUCH_MS -#define BUTTON_TOUCH_MS 400 -#endif - -class ButtonThread : public concurrency::OSThread -{ - public: - static const uint32_t c_holdOffTime = 30000; // hold off 30s after boot - - enum ButtonEventType { - BUTTON_EVENT_NONE, - BUTTON_EVENT_PRESSED, - BUTTON_EVENT_PRESSED_SCREEN, - BUTTON_EVENT_DOUBLE_PRESSED, - BUTTON_EVENT_MULTI_PRESSED, - BUTTON_EVENT_LONG_PRESSED, - BUTTON_EVENT_LONG_RELEASED, - BUTTON_EVENT_TOUCH_LONG_PRESSED, - }; - - ButtonThread(); - int32_t runOnce() override; - void attachButtonInterrupts(); - void detachButtonInterrupts(); - void storeClickCount(); - bool isBuzzing() { return buzzer_flag; } - void setScreenFlag(bool flag) { screen_flag = flag; } - bool getScreenFlag() { return screen_flag; } - - // Disconnect and reconnect interrupts for light sleep -#ifdef ARCH_ESP32 - int beforeLightSleep(void *unused); - int afterLightSleep(esp_sleep_wakeup_cause_t cause); -#endif - private: -#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(USERPREFS_BUTTON_PIN) - static OneButton userButton; // Static - accessed from an interrupt -#endif -#ifdef BUTTON_PIN_ALT - OneButton userButtonAlt; -#endif -#ifdef BUTTON_PIN_TOUCH - OneButton userButtonTouch; -#endif - -#ifdef ARCH_ESP32 - // Get notified when lightsleep begins and ends - CallbackObserver lsObserver = - CallbackObserver(this, &ButtonThread::beforeLightSleep); - CallbackObserver lsEndObserver = - CallbackObserver(this, &ButtonThread::afterLightSleep); -#endif - - // set during IRQ - static volatile ButtonEventType btnEvent; - bool buzzer_flag = false; - bool screen_flag = true; - - // Store click count during callback, for later use - volatile int multipressClickCount = 0; - - static void wakeOnIrq(int irq, int mode); - - static void sendAdHocPosition(); - static void switchPage(); - - // IRQ callbacks - static void userButtonPressed() { btnEvent = BUTTON_EVENT_PRESSED; } - static void userButtonPressedScreen() { btnEvent = BUTTON_EVENT_PRESSED_SCREEN; } - static void userButtonDoublePressed() { btnEvent = BUTTON_EVENT_DOUBLE_PRESSED; } - static void userButtonMultiPressed(void *callerThread); // Retrieve click count from non-static Onebutton while still valid - static void userButtonPressedLongStart(); - static void userButtonPressedLongStop(); - static void touchPressedLongStart() { btnEvent = BUTTON_EVENT_TOUCH_LONG_PRESSED; } -}; - -extern ButtonThread *buttonThread; diff --git a/src/Power.cpp b/src/Power.cpp index a9ed6360e..400b6c6eb 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -661,12 +661,14 @@ bool Power::analogInit() */ bool Power::setup() { - // initialise one power sensor (only) - bool found = axpChipInit(); - if (!found) - found = lipoInit(); - if (!found) - found = analogInit(); + bool found = false; + if (axpChipInit()) { + found = true; + } else if (lipoInit()) { + found = true; + } else if (analogInit()) { + found = true; + } #ifdef NRF_APM found = true; @@ -853,7 +855,8 @@ int32_t Power::runOnce() #ifndef T_WATCH_S3 // FIXME - why is this triggering on the T-Watch S3? if (PMU->isPekeyLongPressIrq()) { LOG_DEBUG("PEK long button press"); - screen->setOn(false); + if (screen) + screen->setOn(false); } #endif diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index dbe4796cf..b3a6b17ef 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -82,7 +82,8 @@ static uint32_t secsSlept; static void lsEnter() { LOG_INFO("lsEnter begin, ls_secs=%u", config.power.ls_secs); - screen->setOn(false); + if (screen) + screen->setOn(false); secsSlept = 0; // How long have we been sleeping this time // LOG_INFO("lsEnter end"); @@ -160,7 +161,8 @@ static void lsExit() static void nbEnter() { LOG_DEBUG("State: NB"); - screen->setOn(false); + if (screen) + screen->setOn(false); #ifdef ARCH_ESP32 // Only ESP32 should turn off bluetooth setBluetoothEnable(false); @@ -172,22 +174,23 @@ static void nbEnter() static void darkEnter() { setBluetoothEnable(true); - screen->setOn(false); + if (screen) + screen->setOn(false); } static void serialEnter() { LOG_DEBUG("State: SERIAL"); setBluetoothEnable(false); - screen->setOn(true); - screen->print("Serial connected\n"); + if (screen) { + screen->setOn(true); + } } static void serialExit() { // Turn bluetooth back on when we leave serial stream API setBluetoothEnable(true); - screen->print("Serial disconnected\n"); } static void powerEnter() @@ -198,15 +201,10 @@ static void powerEnter() LOG_INFO("Loss of power in Powered"); powerFSM.trigger(EVENT_POWER_DISCONNECTED); } else { - screen->setOn(true); + if (screen) + screen->setOn(true); setBluetoothEnable(true); // within enter() the function getState() returns the state we came from - - // Mothballed: print change of power-state to device screen - /* if (strcmp(powerFSM.getState()->name, "BOOT") != 0 && strcmp(powerFSM.getState()->name, "POWER") != 0 && - strcmp(powerFSM.getState()->name, "DARK") != 0) { - screen->print("Powered...\n"); - }*/ } } @@ -221,18 +219,16 @@ static void powerIdle() static void powerExit() { - screen->setOn(true); + if (screen) + screen->setOn(true); setBluetoothEnable(true); - - // Mothballed: print change of power-state to device screen - /*if (!isPowered()) - screen->print("Unpowered...\n");*/ } static void onEnter() { LOG_DEBUG("State: ON"); - screen->setOn(true); + if (screen) + screen->setOn(true); setBluetoothEnable(true); } @@ -244,11 +240,6 @@ static void onIdle() } } -static void screenPress() -{ - screen->onPress(); -} - static void bootEnter() { LOG_DEBUG("State: BOOT"); @@ -292,9 +283,9 @@ void PowerFSM_setup() powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, NULL, "Press"); powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, NULL, "Press"); powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, NULL, "Press"); - powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, screenPress, "Press"); - powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, screenPress, "Press"); // reenter On to restart our timers - powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, screenPress, + powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, NULL, "Press"); + powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, NULL, "Press"); // reenter On to restart our timers + powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, NULL, "Press"); // Allow button to work while in serial API // Handle critically low power battery by forcing deep sleep @@ -328,10 +319,10 @@ void PowerFSM_setup() // if any packet destined for phone arrives, turn on bluetooth at least powerFSM.add_transition(&stateNB, &stateDARK, EVENT_PACKET_FOR_PHONE, NULL, "Packet for phone"); - // show the latest node when we get a new node db update - powerFSM.add_transition(&stateNB, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update"); - powerFSM.add_transition(&stateDARK, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update"); - powerFSM.add_transition(&stateON, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update"); + // Removed 2.7: we don't show the nodes individually for every node on the screen anymore + // powerFSM.add_transition(&stateNB, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update"); + // powerFSM.add_transition(&stateDARK, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update"); + // powerFSM.add_transition(&stateON, &stateON, EVENT_NODEDB_UPDATED, NULL, "NodeDB update"); // Show the received text message powerFSM.add_transition(&stateLS, &stateON, EVENT_RECEIVED_MSG, NULL, "Received text"); diff --git a/src/PowerFSM.h b/src/PowerFSM.h index 13dfdc4cc..beb233f11 100644 --- a/src/PowerFSM.h +++ b/src/PowerFSM.h @@ -11,7 +11,7 @@ #define EVENT_RECEIVED_MSG 5 // #define EVENT_BOOT 6 // now done with a timed transition #define EVENT_BLUETOOTH_PAIR 7 -#define EVENT_NODEDB_UPDATED 8 // NodeDB has a big enough change that we think you should turn on the screen +// #define EVENT_NODEDB_UPDATED 8 // Now defunct: NodeDB has a big enough change that we think you should turn on the screen #define EVENT_CONTACT_FROM_PHONE 9 // the phone just talked to us over bluetooth #define EVENT_LOW_BATTERY 10 // Battery is critically low, go to sleep #define EVENT_SERIAL_CONNECTED 11 diff --git a/src/buzz/BuzzerFeedbackThread.cpp b/src/buzz/BuzzerFeedbackThread.cpp new file mode 100644 index 000000000..2bd3158a3 --- /dev/null +++ b/src/buzz/BuzzerFeedbackThread.cpp @@ -0,0 +1,79 @@ +#include "BuzzerFeedbackThread.h" +#include "NodeDB.h" +#include "buzz.h" +#include "configuration.h" + +BuzzerFeedbackThread *buzzerFeedbackThread; + +BuzzerFeedbackThread::BuzzerFeedbackThread() : OSThread("BuzzerFeedback") +{ + if (inputBroker) + inputObserver.observe(inputBroker); +} + +int BuzzerFeedbackThread::handleInputEvent(const InputEvent *event) +{ + // Only provide feedback if buzzer is enabled for notifications + if (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED || + config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_NOTIFICATIONS_ONLY) { + return 0; // Let other handlers process the event + } + + // Track last event time for potential future use + lastEventTime = millis(); + needsUpdate = true; + + // Handle different input events with appropriate buzzer feedback + switch (event->inputEvent) { + case INPUT_BROKER_USER_PRESS: + case INPUT_BROKER_ALT_PRESS: + case INPUT_BROKER_SELECT: + playBeep(); // Confirmation feedback + break; + + case INPUT_BROKER_UP: + case INPUT_BROKER_DOWN: + case INPUT_BROKER_LEFT: + case INPUT_BROKER_RIGHT: + playChirp(); // Navigation feedback + break; + + case INPUT_BROKER_CANCEL: + case INPUT_BROKER_BACK: + playBoop(); // Cancel/back feedback + break; + + case INPUT_BROKER_SEND_PING: + playComboTune(); // Ping sent feedback + break; + + case INPUT_BROKER_SHUTDOWN: + playShutdownMelody(); // Shutdown feedback + break; + + default: + // For other events, check if it's a printable character + if (event->kbchar >= 32 && event->kbchar <= 126) { + // Typing feedback - very short boop + // Removing this for now, too chatty + // playChirp(); + } + break; + } + + return 0; // Allow other handlers to process the event +} + +int32_t BuzzerFeedbackThread::runOnce() +{ + // This thread is primarily event-driven, but we can use runOnce + // for any periodic tasks if needed in the future + + if (needsUpdate) { + needsUpdate = false; + // Could add any periodic processing here + } + + // Run every 100ms when active, less frequently when idle + return needsUpdate ? 100 : 1000; +} diff --git a/src/buzz/BuzzerFeedbackThread.h b/src/buzz/BuzzerFeedbackThread.h new file mode 100644 index 000000000..dedea9860 --- /dev/null +++ b/src/buzz/BuzzerFeedbackThread.h @@ -0,0 +1,24 @@ +#pragma once + +#include "Observer.h" +#include "concurrency/OSThread.h" +#include "input/InputBroker.h" + +class BuzzerFeedbackThread : public concurrency::OSThread +{ + CallbackObserver inputObserver = + CallbackObserver(this, &BuzzerFeedbackThread::handleInputEvent); + + public: + BuzzerFeedbackThread(); + int handleInputEvent(const InputEvent *event); + + protected: + virtual int32_t runOnce() override; + + private: + uint32_t lastEventTime = 0; + bool needsUpdate = false; +}; + +extern BuzzerFeedbackThread *buzzerFeedbackThread; diff --git a/src/buzz/buzz.cpp b/src/buzz/buzz.cpp index 6ba2f4140..b09d7a82c 100644 --- a/src/buzz/buzz.cpp +++ b/src/buzz/buzz.cpp @@ -38,6 +38,11 @@ const int DURATION_1_1 = 1000; // 1/1 note void playTones(const ToneDuration *tone_durations, int size) { + if (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED || + config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_NOTIFICATIONS_ONLY) { + // Buzzer is disabled or not set to system tones + return; + } #ifdef PIN_BUZZER if (!config.device.buzzer_gpio) config.device.buzzer_gpio = PIN_BUZZER; @@ -54,7 +59,7 @@ void playTones(const ToneDuration *tone_durations, int size) void playBeep() { - ToneDuration melody[] = {{NOTE_B3, DURATION_1_4}}; + ToneDuration melody[] = {{NOTE_B3, DURATION_1_8}}; playTones(melody, sizeof(melody) / sizeof(ToneDuration)); } @@ -87,3 +92,72 @@ void playShutdownMelody() ToneDuration melody[] = {{NOTE_CS4, DURATION_1_8}, {NOTE_AS3, DURATION_1_8}, {NOTE_FS3, DURATION_1_4}}; playTones(melody, sizeof(melody) / sizeof(ToneDuration)); } + +void playChirp() +{ + // A short, friendly "chirp" sound for key presses + ToneDuration melody[] = {{NOTE_AS3, 20}}; // Very short AS3 note + playTones(melody, sizeof(melody) / sizeof(ToneDuration)); +} + +void playBoop() +{ + // A short, friendly "boop" sound for button presses + ToneDuration melody[] = {{NOTE_A3, 50}}; // Very short A3 note + playTones(melody, sizeof(melody) / sizeof(ToneDuration)); +} + +void playLongPressLeadUp() +{ + // An ascending lead-up sequence for long press - builds anticipation + ToneDuration melody[] = { + {NOTE_C3, 100}, // Start low + {NOTE_E3, 100}, // Step up + {NOTE_G3, 100}, // Keep climbing + {NOTE_B3, 150} // Peak with longer note for emphasis + }; + playTones(melody, sizeof(melody) / sizeof(ToneDuration)); +} + +// Static state for progressive lead-up notes +static int leadUpNoteIndex = 0; +static const ToneDuration leadUpNotes[] = { + {NOTE_C3, 100}, // Start low + {NOTE_E3, 100}, // Step up + {NOTE_G3, 100}, // Keep climbing + {NOTE_B3, 150} // Peak with longer note for emphasis +}; +static const int leadUpNotesCount = sizeof(leadUpNotes) / sizeof(ToneDuration); + +bool playNextLeadUpNote() +{ + if (leadUpNoteIndex >= leadUpNotesCount) { + return false; // All notes have been played + } + + // Use playTones to handle buzzer logic consistently + const auto ¬e = leadUpNotes[leadUpNoteIndex]; + playTones(¬e, 1); // Play single note using existing playTones function + + leadUpNoteIndex++; + return true; // Note was played (playTones handles buzzer availability internally) +} + +void resetLeadUpSequence() +{ + leadUpNoteIndex = 0; +} + +void playComboTune() +{ + // Quick high-pitched notes with trills + ToneDuration melody[] = { + {NOTE_G3, 80}, // Quick chirp + {NOTE_B3, 60}, // Higher chirp + {NOTE_CS4, 80}, // Even higher + {NOTE_G3, 60}, // Quick trill down + {NOTE_CS4, 60}, // Quick trill up + {NOTE_B3, 120} // Ending chirp + }; + playTones(melody, sizeof(melody) / sizeof(ToneDuration)); +} diff --git a/src/buzz/buzz.h b/src/buzz/buzz.h index adeaca73d..c25a54a5b 100644 --- a/src/buzz/buzz.h +++ b/src/buzz/buzz.h @@ -5,4 +5,10 @@ void playLongBeep(); void playStartMelody(); void playShutdownMelody(); void playGPSEnableBeep(); -void playGPSDisableBeep(); \ No newline at end of file +void playGPSDisableBeep(); +void playComboTune(); +void playBoop(); +void playChirp(); +void playLongPressLeadUp(); +bool playNextLeadUpNote(); // Play the next note in the lead-up sequence +void resetLeadUpSequence(); // Reset the lead-up sequence to start from beginning \ No newline at end of file diff --git a/src/commands.h b/src/commands.h index f2b783010..e0bfab330 100644 --- a/src/commands.h +++ b/src/commands.h @@ -12,7 +12,6 @@ enum class Cmd { STOP_ALERT_FRAME, START_FIRMWARE_UPDATE_SCREEN, STOP_BOOT_SCREEN, - PRINT, SHOW_PREV_FRAME, SHOW_NEXT_FRAME }; \ No newline at end of file diff --git a/src/configuration.h b/src/configuration.h index 33e014c5e..89257ff2f 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -207,6 +207,7 @@ along with this program. If not, see . #define BMX160_ADDR 0x69 #define ICM20948_ADDR 0x69 #define ICM20948_ADDR_ALT 0x68 +#define BMM150_ADDR 0x13 // ----------------------------------------------------------------------------- // LED @@ -347,11 +348,41 @@ along with this program. If not, see . #error HW_VENDOR must be defined #endif +#ifndef TB_DOWN +#define TB_DOWN 255 +#endif +#ifndef TB_UP +#define TB_UP 255 +#endif +#ifndef TB_LEFT +#define TB_LEFT 255 +#endif +#ifndef TB_RIGHT +#define TB_RIGHT 255 +#endif +#ifndef TB_PRESS +#define TB_PRESS 255 +#endif + // Support multiple RGB LED configuration #if defined(HAS_NCP5623) || defined(HAS_LP5562) || defined(RGBLED_RED) || defined(HAS_NEOPIXEL) || defined(UNPHONE) #define HAS_RGB_LED #endif +// default mapping of pins +#if defined(PIN_BUTTON2) && !defined(CANCEL_BUTTON_PIN) +#define ALT_BUTTON_PIN PIN_BUTTON2 +#endif +#if defined ALT_BUTTON_PIN + +#ifndef ALT_BUTTON_ACTIVE_LOW +#define ALT_BUTTON_ACTIVE_LOW true +#endif +#ifndef ALT_BUTTON_ACTIVE_PULLUP +#define ALT_BUTTON_ACTIVE_PULLUP true +#endif +#endif + // ----------------------------------------------------------------------------- // Global switches to turn off features for a minimized build // ----------------------------------------------------------------------------- diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index 5bd5c0d12..e6236251c 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -37,8 +37,8 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const { - ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX, ICM20948, QMA6100P}; - return firstOfOrNONE(8, types); + ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX, ICM20948, QMA6100P, BMM150}; + return firstOfOrNONE(9, types); } ScanI2C::FoundDevice ScanI2C::firstRGBLED() const diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 1e91933a9..90467abd0 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -73,6 +73,7 @@ class ScanI2C RAK12035, TCA8418KB, PCT2075, + BMM150, } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 09f320908..fd3d1c80b 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -447,6 +447,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) SCAN_SIMPLE_CASE(DFROBOT_RAIN_ADDR, DFROBOT_RAIN, "DFRobot Rain Gauge", (uint8_t)addr.address); SCAN_SIMPLE_CASE(LTR390UV_ADDR, LTR390UV, "LTR390UV", (uint8_t)addr.address); SCAN_SIMPLE_CASE(PCT2075_ADDR, PCT2075, "PCT2075", (uint8_t)addr.address); + SCAN_SIMPLE_CASE(BMM150_ADDR, BMM150, "BMM150", (uint8_t)addr.address); #ifdef HAS_TPS65233 SCAN_SIMPLE_CASE(TPS65233_ADDR, TPS65233, "TPS65233", (uint8_t)addr.address); #endif diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 61999ee79..975cf71a9 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1,9 +1,10 @@ /* +BaseUI -SSD1306 - Screen module - -Copyright (C) 2018 by Xose Pérez - +Developed and Maintained By: +- Ronald Garcia (HarukiToreda) – Lead development and implementation. +- JasonP (Xaositek) – Screen layout and icon design, UI improvements and testing. +- TonyG (Tropho) – Project management, structural planning, and testing This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -27,18 +28,30 @@ along with this program. If not, see . #include #include "DisplayFormatters.h" +#include "TimeFormatters.h" +#include "draw/ClockRenderer.h" +#include "draw/DebugRenderer.h" +#include "draw/MessageRenderer.h" +#include "draw/NodeListRenderer.h" +#include "draw/NotificationRenderer.h" +#include "draw/UIRenderer.h" +#include "modules/CannedMessageModule.h" + #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" +#include "buzz.h" #endif -#include "ButtonThread.h" +#include "FSCommon.h" #include "MeshService.h" #include "NodeDB.h" +#include "RadioLibInterface.h" #include "error.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" #include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/emotes.h" #include "graphics/images.h" -#include "input/ScanAndSelect.h" #include "input/TouchScreenImpl1.h" #include "main.h" #include "mesh-pb-constants.h" @@ -52,13 +65,15 @@ along with this program. If not, see . #include "sleep.h" #include "target_specific.h" +using graphics::Emote; +using graphics::emotes; +using graphics::numEmotes; + #if HAS_WIFI && !defined(ARCH_PORTDUINO) #include "mesh/wifi/WiFiAPClient.h" #endif #ifdef ARCH_ESP32 -#include "esp_task_wdt.h" -#include "modules/StoreForwardModule.h" #endif #if ARCH_PORTDUINO @@ -82,14 +97,10 @@ namespace graphics // A text message frame + debug frame + all the node infos FrameCallback *normalFrames; static uint32_t targetFramerate = IDLE_FRAMERATE; +// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization uint32_t logo_timeout = 5000; // 4 seconds for EACH logo -uint32_t hours_in_month = 730; - -// This image definition is here instead of images.h because it's modified dynamically by the drawBattery function -uint8_t imgBattery[16] = {0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xE7, 0x3C}; - // Threshold values for the GPS lock accuracy bar display uint32_t dopThresholds[5] = {2000, 1000, 500, 200, 100}; @@ -97,13 +108,9 @@ uint32_t dopThresholds[5] = {2000, 1000, 500, 200, 100}; // we'll need to hold onto pointers for the modules that can draw a frame. std::vector moduleFrames; -// Stores the last 4 of our hardware ID, to make finding the device for pairing easier -static char ourId[5]; - -// vector where symbols (string) are displayed in bottom corner of display. +// Global variables for screen function overlay symbols std::vector functionSymbol; -// string displayed in bottom right corner of display. Created from elements in functionSymbol vector -std::string functionSymbolString = ""; +std::string functionSymbolString; #if HAS_GPS // GeoCoord object for the screen @@ -114,258 +121,38 @@ GeoCoord geoCoord; static bool heartbeat = false; #endif -// Quick access to screen dimensions from static drawing functions -// DEPRECATED. To-do: move static functions inside Screen class -#define SCREEN_WIDTH display->getWidth() -#define SCREEN_HEIGHT display->getHeight() - #include "graphics/ScreenFonts.h" #include -#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) +// Usage: int stringWidth = formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display); +// End Functions to write date/time to the screen -// Check if the display can render a string (detect special chars; emoji) -static bool haveGlyphs(const char *str) +extern bool hasUnreadMessage; + +// ============================== +// Overlay Alert Banner Renderer +// ============================== +// Displays a temporary centered banner message (e.g., warning, status, etc.) +// The banner appears in the center of the screen and disappears after the specified duration + +// Called to trigger a banner with custom message and duration +void Screen::showOverlayBanner(const char *message, uint32_t durationMs, uint8_t options, std::function bannerCallback, + int8_t InitialSelected) { -#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS) - // Don't want to make any assumptions about custom language support - return true; -#endif - - // Check each character with the lookup function for the OLED library - // We're not really meant to use this directly.. - bool have = true; - for (uint16_t i = 0; i < strlen(str); i++) { - uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]); - // If font doesn't support a character, it is substituted for ¿ - if (result == 191 && (uint8_t)str[i] != 191) { - have = false; - break; - } - } - - LOG_DEBUG("haveGlyphs=%d", have); - return have; + // Store the message and set the expiration timestamp + strncpy(NotificationRenderer::alertBannerMessage, message, 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerOptions = options; + NotificationRenderer::alertBannerCallback = bannerCallback; + NotificationRenderer::curSelected = InitialSelected; + NotificationRenderer::pauseBanner = false; + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawAlertBannerOverlay}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + setFastFramerate(); // Draw ASAP + ui->update(); } -/** - * Draw the icon with extra info printed around the corners - */ -static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - // draw an xbm image. - // Please note that everything that should be transitioned - // needs to be drawn relative to x and y - - // draw centered icon left to right and centered above the one line of app text - display->drawXbm(x + (SCREEN_WIDTH - icon_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - icon_height) / 2 + 2, - icon_width, icon_height, icon_bits); - - display->setFont(FONT_MEDIUM); - display->setTextAlignment(TEXT_ALIGN_LEFT); - const char *title = "meshtastic.org"; - display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); - display->setFont(FONT_SMALL); - - // Draw region in upper left - if (upperMsg) - display->drawString(x + 0, y + 0, upperMsg); - - // Draw version and short name in upper right - char buf[25]; - snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); - - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + SCREEN_WIDTH, y + 0, buf); - screen->forceDisplay(); - - display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code -} - -#ifdef USERPREFS_OEM_TEXT - -static void drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - static const uint8_t xbm[] = USERPREFS_OEM_IMAGE_DATA; - display->drawXbm(x + (SCREEN_WIDTH - USERPREFS_OEM_IMAGE_WIDTH) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - USERPREFS_OEM_IMAGE_HEIGHT) / 2 + 2, USERPREFS_OEM_IMAGE_WIDTH, - USERPREFS_OEM_IMAGE_HEIGHT, xbm); - - switch (USERPREFS_OEM_FONT_SIZE) { - case 0: - display->setFont(FONT_SMALL); - break; - case 2: - display->setFont(FONT_LARGE); - break; - default: - display->setFont(FONT_MEDIUM); - break; - } - - display->setTextAlignment(TEXT_ALIGN_LEFT); - const char *title = USERPREFS_OEM_TEXT; - display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); - display->setFont(FONT_SMALL); - - // Draw region in upper left - if (upperMsg) - display->drawString(x + 0, y + 0, upperMsg); - - // Draw version and shortname in upper right - char buf[25]; - snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); - - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + SCREEN_WIDTH, y + 0, buf); - screen->forceDisplay(); - - display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code -} - -static void drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - // Draw region in upper left - const char *region = myRegion ? myRegion->name : NULL; - drawOEMIconScreen(region, display, state, x, y); -} - -#endif - -void Screen::drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *message) -{ - uint16_t x_offset = display->width() / 2; - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_MEDIUM); - display->drawString(x_offset + x, 26 + y, message); -} - -// Used on boot when a certificate is being created -static void drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_SMALL); - display->drawString(64 + x, y, "Creating SSL certificate"); - -#ifdef ARCH_ESP32 - yield(); - esp_task_wdt_reset(); -#endif - - display->setFont(FONT_SMALL); - if ((millis() / 1000) % 2) { - display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ."); - } else { - display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . "); - } -} - -// Used when booting without a region set -static void drawWelcomeScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(64 + x, y, "//\\ E S H T /\\ S T / C"); - display->drawString(64 + x, y + FONT_HEIGHT_SMALL, getDeviceName()); - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if ((millis() / 10000) % 2) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Set the region using the"); - display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "Meshtastic Android, iOS,"); - display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, "Web or CLI clients."); - } else { - display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Visit meshtastic.org"); - display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "for more information."); - display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, ""); - } - -#ifdef ARCH_ESP32 - yield(); - esp_task_wdt_reset(); -#endif -} - -// draw overlay in bottom right corner of screen to show when notifications are muted or modifier key is active -static void drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) -{ - // LOG_DEBUG("Draw function overlay"); - if (functionSymbol.begin() != functionSymbol.end()) { - char buf[64]; - display->setFont(FONT_SMALL); - snprintf(buf, sizeof(buf), "%s", functionSymbolString.c_str()); - display->drawString(SCREEN_WIDTH - display->getStringWidth(buf), SCREEN_HEIGHT - FONT_HEIGHT_SMALL, buf); - } -} - -#ifdef USE_EINK -/// Used on eink displays while in deep sleep -static void drawDeepSleepScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - - // Next frame should use full-refresh, and block while running, else device will sleep before async callback - EINK_ADD_FRAMEFLAG(display, COSMETIC); - EINK_ADD_FRAMEFLAG(display, BLOCKING); - - LOG_DEBUG("Draw deep sleep screen"); - - // Display displayStr on the screen - drawIconScreen("Sleeping", display, state, x, y); -} - -/// Used on eink displays when screen updates are paused -static void drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) -{ - LOG_DEBUG("Draw screensaver overlay"); - - EINK_ADD_FRAMEFLAG(display, COSMETIC); // Take the opportunity for a full-refresh - - // Config - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - const char *pauseText = "Screen Paused"; - const char *idText = owner.short_name; - const bool useId = haveGlyphs(idText); // This bool is used to hide the idText box if we can't render the short name - constexpr uint16_t padding = 5; - constexpr uint8_t dividerGap = 1; - constexpr uint8_t imprecision = 5; // How far the box origins can drift from center. Combat burn-in. - - // Dimensions - const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true); // "true": handle utf8 chars - const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText)); - const uint16_t boxWidth = padding + (useId ? idTextWidth + padding + padding : 0) + pauseTextWidth + padding; - const uint16_t boxHeight = padding + FONT_HEIGHT_SMALL + padding; - - // Position - const int16_t boxLeft = (display->width() / 2) - (boxWidth / 2) + random(-imprecision, imprecision + 1); - // const int16_t boxRight = boxLeft + boxWidth - 1; - const int16_t boxTop = (display->height() / 2) - (boxHeight / 2 + random(-imprecision, imprecision + 1)); - const int16_t boxBottom = boxTop + boxHeight - 1; - const int16_t idTextLeft = boxLeft + padding; - const int16_t idTextTop = boxTop + padding; - const int16_t pauseTextLeft = boxLeft + (useId ? padding + idTextWidth + padding : 0) + padding; - const int16_t pauseTextTop = boxTop + padding; - const int16_t dividerX = boxLeft + padding + idTextWidth + padding; - const int16_t dividerTop = boxTop + 1 + dividerGap; - const int16_t dividerBottom = boxBottom - 1 - dividerGap; - - // Draw: box - display->setColor(EINK_WHITE); - display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Clear a slightly oversized area for the box - display->setColor(EINK_BLACK); - display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); - - // Draw: Text - if (useId) - display->drawString(idTextLeft, idTextTop, idText); - display->drawString(pauseTextLeft, pauseTextTop, pauseText); - display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold - - // Draw: divider - if (useId) - display->drawLine(dividerX, dividerTop, dividerX, dividerBottom); -} -#endif - static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { uint8_t module_frame; @@ -388,875 +175,12 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int pi.drawFrame(display, state, x, y); } -static void drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_MEDIUM); - display->drawString(64 + x, y, "Updating"); - - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(), - "Please be patient and do not power off."); -} - -/// Draw the last text message we received -static void drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_MEDIUM); - - char tempBuf[24]; - snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code); - display->drawString(0 + x, 0 + y, tempBuf); - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org"); -} - // Ignore messages originating from phone (from the current node 0x0) unless range test or store and forward module are enabled static bool shouldDrawMessage(const meshtastic_MeshPacket *packet) { return packet->from != 0 && !moduleConfig.store_forward.enabled; } -// Draw power bars or a charging indicator on an image of a battery, determined by battery charge voltage or percentage. -static void drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer, const PowerStatus *powerStatus) -{ - static const uint8_t powerBar[3] = {0x81, 0xBD, 0xBD}; - static const uint8_t lightning[8] = {0xA1, 0xA1, 0xA5, 0xAD, 0xB5, 0xA5, 0x85, 0x85}; - // Clear the bar area on the battery image - for (int i = 1; i < 14; i++) { - imgBuffer[i] = 0x81; - } - // If charging, draw a charging indicator - if (powerStatus->getIsCharging()) { - memcpy(imgBuffer + 3, lightning, 8); - // If not charging, Draw power bars - } else { - for (int i = 0; i < 4; i++) { - if (powerStatus->getBatteryChargePercent() >= 25 * i) - memcpy(imgBuffer + 1 + (i * 3), powerBar, 3); - } - } - display->drawFastImage(x, y, 16, 8, imgBuffer); -} - -#if defined(DISPLAY_CLOCK_FRAME) - -void Screen::drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode, float scale) -{ - uint16_t segmentWidth = SEGMENT_WIDTH * scale; - uint16_t segmentHeight = SEGMENT_HEIGHT * scale; - - if (digitalMode) { - uint16_t radius = (segmentWidth + (segmentHeight * 2) + 4) / 2; - uint16_t centerX = (x + segmentHeight + 2) + (radius / 2); - uint16_t centerY = (y + segmentHeight + 2) + (radius / 2); - - display->drawCircle(centerX, centerY, radius); - display->drawCircle(centerX, centerY, radius + 1); - display->drawLine(centerX, centerY, centerX, centerY - radius + 3); - display->drawLine(centerX, centerY, centerX + radius - 3, centerY); - } else { - uint16_t segmentOneX = x + segmentHeight + 2; - uint16_t segmentOneY = y; - - uint16_t segmentTwoX = segmentOneX + segmentWidth + 2; - uint16_t segmentTwoY = segmentOneY + segmentHeight + 2; - - uint16_t segmentThreeX = segmentOneX; - uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2; - - uint16_t segmentFourX = x; - uint16_t segmentFourY = y + segmentHeight + 2; - - drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight); - drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight); - drawHorizontalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight); - drawVerticalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight); - } -} - -// Draw a digital clock -void Screen::drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setTextAlignment(TEXT_ALIGN_LEFT); - - drawBattery(display, x, y + 7, imgBattery, powerStatus); - - if (powerStatus->getHasBattery()) { - String batteryPercent = String(powerStatus->getBatteryChargePercent()) + "%"; - - display->setFont(FONT_SMALL); - - display->drawString(x + 20, y + 2, batteryPercent); - } - - if (nimbleBluetooth && nimbleBluetooth->isConnected()) { - drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2); - } - - drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36, screen->digitalWatchFace, 1); - - display->setColor(OLEDDISPLAY_COLOR::WHITE); - - uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone - if (rtc_sec > 0) { - long hms = rtc_sec % SEC_PER_DAY; - hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; - - int hour = hms / SEC_PER_HOUR; - int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; - int second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN - - hour = hour > 12 ? hour - 12 : hour; - - if (hour == 0) { - hour = 12; - } - - // hours string - String hourString = String(hour); - - // minutes string - String minuteString = minute < 10 ? "0" + String(minute) : String(minute); - - String timeString = hourString + ":" + minuteString; - - // seconds string - String secondString = second < 10 ? "0" + String(second) : String(second); - - float scale = 1.5; - - uint16_t segmentWidth = SEGMENT_WIDTH * scale; - uint16_t segmentHeight = SEGMENT_HEIGHT * scale; - - // calculate hours:minutes string width - uint16_t timeStringWidth = timeString.length() * 5; - - for (uint8_t i = 0; i < timeString.length(); i++) { - String character = String(timeString[i]); - - if (character == ":") { - timeStringWidth += segmentHeight; - } else { - timeStringWidth += segmentWidth + (segmentHeight * 2) + 4; - } - } - - // calculate seconds string width - uint16_t secondStringWidth = (secondString.length() * 12) + 4; - - // sum these to get total string width - uint16_t totalWidth = timeStringWidth + secondStringWidth; - - uint16_t hourMinuteTextX = (display->getWidth() / 2) - (totalWidth / 2); - - uint16_t startingHourMinuteTextX = hourMinuteTextX; - - uint16_t hourMinuteTextY = (display->getHeight() / 2) - (((segmentWidth * 2) + (segmentHeight * 3) + 8) / 2); - - // iterate over characters in hours:minutes string and draw segmented characters - for (uint8_t i = 0; i < timeString.length(); i++) { - String character = String(timeString[i]); - - if (character == ":") { - drawSegmentedDisplayColon(display, hourMinuteTextX, hourMinuteTextY, scale); - - hourMinuteTextX += segmentHeight + 6; - } else { - drawSegmentedDisplayCharacter(display, hourMinuteTextX, hourMinuteTextY, character.toInt(), scale); - - hourMinuteTextX += segmentWidth + (segmentHeight * 2) + 4; - } - - hourMinuteTextX += 5; - } - - // draw seconds string - display->setFont(FONT_MEDIUM); - display->drawString(startingHourMinuteTextX + timeStringWidth + 4, - (display->getHeight() - hourMinuteTextY) - FONT_HEIGHT_MEDIUM + 6, secondString); - } -} - -void Screen::drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale) -{ - uint16_t segmentWidth = SEGMENT_WIDTH * scale; - uint16_t segmentHeight = SEGMENT_HEIGHT * scale; - - uint16_t cellHeight = (segmentWidth * 2) + (segmentHeight * 3) + 8; - - uint16_t topAndBottomX = x + (4 * scale); - - uint16_t quarterCellHeight = cellHeight / 4; - - uint16_t topY = y + quarterCellHeight; - uint16_t bottomY = y + (quarterCellHeight * 3); - - display->fillRect(topAndBottomX, topY, segmentHeight, segmentHeight); - display->fillRect(topAndBottomX, bottomY, segmentHeight, segmentHeight); -} - -void Screen::drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale) -{ - // the numbers 0-9, each expressed as an array of seven boolean (0|1) values encoding the on/off state of - // segment {innerIndex + 1} - // e.g., to display the numeral '0', segments 1-6 are on, and segment 7 is off. - uint8_t numbers[10][7] = { - {1, 1, 1, 1, 1, 1, 0}, // 0 Display segment key - {0, 1, 1, 0, 0, 0, 0}, // 1 1 - {1, 1, 0, 1, 1, 0, 1}, // 2 ___ - {1, 1, 1, 1, 0, 0, 1}, // 3 6 | | 2 - {0, 1, 1, 0, 0, 1, 1}, // 4 |_7̲_| - {1, 0, 1, 1, 0, 1, 1}, // 5 5 | | 3 - {1, 0, 1, 1, 1, 1, 1}, // 6 |___| - {1, 1, 1, 0, 0, 1, 0}, // 7 - {1, 1, 1, 1, 1, 1, 1}, // 8 4 - {1, 1, 1, 1, 0, 1, 1}, // 9 - }; - - // the width and height of each segment's central rectangle: - // _____________________ - // ⋰| (only this part, |⋱ - // ⋰ | not including | ⋱ - // ⋱ | the triangles | ⋰ - // ⋱| on the ends) |⋰ - // ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ - - uint16_t segmentWidth = SEGMENT_WIDTH * scale; - uint16_t segmentHeight = SEGMENT_HEIGHT * scale; - - // segment x and y coordinates - uint16_t segmentOneX = x + segmentHeight + 2; - uint16_t segmentOneY = y; - - uint16_t segmentTwoX = segmentOneX + segmentWidth + 2; - uint16_t segmentTwoY = segmentOneY + segmentHeight + 2; - - uint16_t segmentThreeX = segmentTwoX; - uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2 + segmentHeight + 2; - - uint16_t segmentFourX = segmentOneX; - uint16_t segmentFourY = segmentThreeY + segmentWidth + 2; - - uint16_t segmentFiveX = x; - uint16_t segmentFiveY = segmentThreeY; - - uint16_t segmentSixX = x; - uint16_t segmentSixY = segmentTwoY; - - uint16_t segmentSevenX = segmentOneX; - uint16_t segmentSevenY = segmentTwoY + segmentWidth + 2; - - if (numbers[number][0]) { - drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight); - } - - if (numbers[number][1]) { - drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight); - } - - if (numbers[number][2]) { - drawVerticalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight); - } - - if (numbers[number][3]) { - drawHorizontalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight); - } - - if (numbers[number][4]) { - drawVerticalSegment(display, segmentFiveX, segmentFiveY, segmentWidth, segmentHeight); - } - - if (numbers[number][5]) { - drawVerticalSegment(display, segmentSixX, segmentSixY, segmentWidth, segmentHeight); - } - - if (numbers[number][6]) { - drawHorizontalSegment(display, segmentSevenX, segmentSevenY, segmentWidth, segmentHeight); - } -} - -void Screen::drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height) -{ - int halfHeight = height / 2; - - // draw central rectangle - display->fillRect(x, y, width, height); - - // draw end triangles - display->fillTriangle(x, y, x, y + height - 1, x - halfHeight, y + halfHeight); - - display->fillTriangle(x + width, y, x + width + halfHeight, y + halfHeight, x + width, y + height - 1); -} - -void Screen::drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height) -{ - int halfHeight = height / 2; - - // draw central rectangle - display->fillRect(x, y, height, width); - - // draw end triangles - display->fillTriangle(x + halfHeight, y - halfHeight, x + height - 1, y, x, y); - - display->fillTriangle(x, y + width, x + height - 1, y + width, x + halfHeight, y + width + halfHeight); -} - -void Screen::drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y) -{ - display->drawFastImage(x, y, 18, 14, bluetoothConnectedIcon); -} - -// Draw an analog clock -void Screen::drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setTextAlignment(TEXT_ALIGN_LEFT); - - drawBattery(display, x, y + 7, imgBattery, powerStatus); - - if (powerStatus->getHasBattery()) { - String batteryPercent = String(powerStatus->getBatteryChargePercent()) + "%"; - - display->setFont(FONT_SMALL); - - display->drawString(x + 20, y + 2, batteryPercent); - } - - if (nimbleBluetooth && nimbleBluetooth->isConnected()) { - drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2); - } - - drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36, screen->digitalWatchFace, 1); - - // clock face center coordinates - int16_t centerX = display->getWidth() / 2; - int16_t centerY = display->getHeight() / 2; - - // clock face radius - int16_t radius = (display->getWidth() / 2) * 0.8; - - // noon (0 deg) coordinates (outermost circle) - int16_t noonX = centerX; - int16_t noonY = centerY - radius; - - // second hand radius and y coordinate (outermost circle) - int16_t secondHandNoonY = noonY + 1; - - // tick mark outer y coordinate; (first nested circle) - int16_t tickMarkOuterNoonY = secondHandNoonY; - - // seconds tick mark inner y coordinate; (second nested circle) - double secondsTickMarkInnerNoonY = (double)noonY + 8; - - // hours tick mark inner y coordinate; (third nested circle) - double hoursTickMarkInnerNoonY = (double)noonY + 16; - - // minute hand y coordinate - int16_t minuteHandNoonY = secondsTickMarkInnerNoonY + 4; - - // hour string y coordinate - int16_t hourStringNoonY = minuteHandNoonY + 18; - - // hour hand radius and y coordinate - int16_t hourHandRadius = radius * 0.55; - int16_t hourHandNoonY = centerY - hourHandRadius; - - display->setColor(OLEDDISPLAY_COLOR::WHITE); - display->drawCircle(centerX, centerY, radius); - - uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone - if (rtc_sec > 0) { - long hms = rtc_sec % SEC_PER_DAY; - hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; - - // Tear apart hms into h:m:s - int hour = hms / SEC_PER_HOUR; - int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; - int second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN - - hour = hour > 12 ? hour - 12 : hour; - - int16_t degreesPerHour = 30; - int16_t degreesPerMinuteOrSecond = 6; - - double hourBaseAngle = hour * degreesPerHour; - double hourAngleOffset = ((double)minute / 60) * degreesPerHour; - double hourAngle = radians(hourBaseAngle + hourAngleOffset); - - double minuteBaseAngle = minute * degreesPerMinuteOrSecond; - double minuteAngleOffset = ((double)second / 60) * degreesPerMinuteOrSecond; - double minuteAngle = radians(minuteBaseAngle + minuteAngleOffset); - - double secondAngle = radians(second * degreesPerMinuteOrSecond); - - double hourX = sin(-hourAngle) * (hourHandNoonY - centerY) + noonX; - double hourY = cos(-hourAngle) * (hourHandNoonY - centerY) + centerY; - - double minuteX = sin(-minuteAngle) * (minuteHandNoonY - centerY) + noonX; - double minuteY = cos(-minuteAngle) * (minuteHandNoonY - centerY) + centerY; - - double secondX = sin(-secondAngle) * (secondHandNoonY - centerY) + noonX; - double secondY = cos(-secondAngle) * (secondHandNoonY - centerY) + centerY; - - display->setFont(FONT_MEDIUM); - - // draw minute and hour tick marks and hour numbers - for (uint16_t angle = 0; angle < 360; angle += 6) { - double angleInRadians = radians(angle); - - double sineAngleInRadians = sin(-angleInRadians); - double cosineAngleInRadians = cos(-angleInRadians); - - double endX = sineAngleInRadians * (tickMarkOuterNoonY - centerY) + noonX; - double endY = cosineAngleInRadians * (tickMarkOuterNoonY - centerY) + centerY; - - if (angle % degreesPerHour == 0) { - double startX = sineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + noonX; - double startY = cosineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + centerY; - - // draw hour tick mark - display->drawLine(startX, startY, endX, endY); - - static char buffer[2]; - - uint8_t hourInt = (angle / 30); - - if (hourInt == 0) { - hourInt = 12; - } - - // hour number x offset needs to be adjusted for some cases - int8_t hourStringXOffset; - int8_t hourStringYOffset = 13; - - switch (hourInt) { - case 3: - hourStringXOffset = 5; - break; - case 9: - hourStringXOffset = 7; - break; - case 10: - case 11: - hourStringXOffset = 8; - break; - case 12: - hourStringXOffset = 13; - break; - default: - hourStringXOffset = 6; - break; - } - - double hourStringX = (sineAngleInRadians * (hourStringNoonY - centerY) + noonX) - hourStringXOffset; - double hourStringY = (cosineAngleInRadians * (hourStringNoonY - centerY) + centerY) - hourStringYOffset; - - // draw hour number - display->drawStringf(hourStringX, hourStringY, buffer, "%d", hourInt); - } - - if (angle % degreesPerMinuteOrSecond == 0) { - double startX = sineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + noonX; - double startY = cosineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + centerY; - - // draw minute tick mark - display->drawLine(startX, startY, endX, endY); - } - } - - // draw hour hand - display->drawLine(centerX, centerY, hourX, hourY); - - // draw minute hand - display->drawLine(centerX, centerY, minuteX, minuteY); - - // draw second hand - display->drawLine(centerX, centerY, secondX, secondY); - } -} - -#endif - -// Get an absolute time from "seconds ago" info. Returns false if no valid timestamp possible -bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int32_t *daysAgo) -{ - // Cache the result - avoid frequent recalculation - static uint8_t hoursCached = 0, minutesCached = 0; - static uint32_t daysAgoCached = 0; - static uint32_t secondsAgoCached = 0; - static bool validCached = false; - - // Abort: if timezone not set - if (strlen(config.device.tzdef) == 0) { - validCached = false; - return validCached; - } - - // Abort: if invalid pointers passed - if (hours == nullptr || minutes == nullptr || daysAgo == nullptr) { - validCached = false; - return validCached; - } - - // Abort: if time seems invalid.. (> 6 months ago, probably seen before RTC set) - if (secondsAgo > SEC_PER_DAY * 30UL * 6) { - validCached = false; - return validCached; - } - - // If repeated request, don't bother recalculating - if (secondsAgo - secondsAgoCached < 60 && secondsAgoCached != 0) { - if (validCached) { - *hours = hoursCached; - *minutes = minutesCached; - *daysAgo = daysAgoCached; - } - return validCached; - } - - // Get local time - uint32_t secondsRTC = getValidTime(RTCQuality::RTCQualityDevice, true); // Get local time - - // Abort: if RTC not set - if (!secondsRTC) { - validCached = false; - return validCached; - } - - // Get absolute time when last seen - uint32_t secondsSeenAt = secondsRTC - secondsAgo; - - // Calculate daysAgo - *daysAgo = (secondsRTC / SEC_PER_DAY) - (secondsSeenAt / SEC_PER_DAY); // How many "midnights" have passed - - // Get seconds since midnight - uint32_t hms = (secondsRTC - secondsAgo) % SEC_PER_DAY; - hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; - - // Tear apart hms into hours and minutes - *hours = hms / SEC_PER_HOUR; - *minutes = (hms % SEC_PER_HOUR) / SEC_PER_MIN; - - // Cache the result - daysAgoCached = *daysAgo; - hoursCached = *hours; - minutesCached = *minutes; - secondsAgoCached = secondsAgo; - - validCached = true; - return validCached; -} - -/// Draw the last text message we received -static void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - // the max length of this buffer is much longer than we can possibly print - static char tempBuf[237]; - - const meshtastic_MeshPacket &mp = devicestate.rx_text_message; - meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp)); - // LOG_DEBUG("Draw text message from 0x%x: %s", mp.from, - // mp.decoded.variant.data.decoded.bytes); - - // Demo for drawStringMaxWidth: - // with the third parameter you can define the width after which words will - // be wrapped. Currently only spaces and "-" are allowed for wrapping - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); - display->setColor(BLACK); - } - - // For time delta - uint32_t seconds = sinceReceived(&mp); - uint32_t minutes = seconds / 60; - uint32_t hours = minutes / 60; - uint32_t days = hours / 24; - - // For timestamp - uint8_t timestampHours, timestampMinutes; - int32_t daysAgo; - bool useTimestamp = deltaToTimestamp(seconds, ×tampHours, ×tampMinutes, &daysAgo); - - // If bold, draw twice, shifting right by one pixel - for (uint8_t xOff = 0; xOff <= (config.display.heading_bold ? 1 : 0); xOff++) { - // Show a timestamp if received today, but longer than 15 minutes ago - if (useTimestamp && minutes >= 15 && daysAgo == 0) { - display->drawStringf(xOff + x, 0 + y, tempBuf, "At %02hu:%02hu from %s", timestampHours, timestampMinutes, - (node && node->has_user) ? node->user.short_name : "???"); - } - // Timestamp yesterday (if display is wide enough) - else if (useTimestamp && daysAgo == 1 && display->width() >= 200) { - display->drawStringf(xOff + x, 0 + y, tempBuf, "Yesterday %02hu:%02hu from %s", timestampHours, timestampMinutes, - (node && node->has_user) ? node->user.short_name : "???"); - } - // Otherwise, show a time delta - else { - display->drawStringf(xOff + x, 0 + y, tempBuf, "%s ago from %s", - screen->drawTimeDelta(days, hours, minutes, seconds).c_str(), - (node && node->has_user) ? node->user.short_name : "???"); - } - } - - display->setColor(WHITE); -#ifndef EXCLUDE_EMOJI - const char *msg = reinterpret_cast(mp.decoded.payload.bytes); - if (strcmp(msg, "\U0001F44D") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - thumbs_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - thumbs_height) / 2 + 2 + 5, thumbs_width, thumbs_height, - thumbup); - } else if (strcmp(msg, "\U0001F44E") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - thumbs_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - thumbs_height) / 2 + 2 + 5, thumbs_width, thumbs_height, - thumbdown); - } else if (strcmp(msg, "\U0001F60A") == 0 || strcmp(msg, "\U0001F600") == 0 || strcmp(msg, "\U0001F642") == 0 || - strcmp(msg, "\U0001F609") == 0 || - strcmp(msg, "\U0001F601") == 0) { // matches 5 different common smileys, so that the phone user doesn't have to - // remember which one is compatible - display->drawXbm(x + (SCREEN_WIDTH - smiley_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - smiley_height) / 2 + 2 + 5, smiley_width, smiley_height, - smiley); - } else if (strcmp(msg, "❓") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - question_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - question_height) / 2 + 2 + 5, question_width, question_height, - question); - } else if (strcmp(msg, "‼️") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - bang_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - bang_height) / 2 + 2 + 5, - bang_width, bang_height, bang); - } else if (strcmp(msg, "\U0001F4A9") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - poo_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - poo_height) / 2 + 2 + 5, - poo_width, poo_height, poo); - } else if (strcmp(msg, "\U0001F923") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - haha_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - haha_height) / 2 + 2 + 5, - haha_width, haha_height, haha); - } else if (strcmp(msg, "\U0001F44B") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - wave_icon_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - wave_icon_height) / 2 + 2 + 5, wave_icon_width, - wave_icon_height, wave_icon); - } else if (strcmp(msg, "\U0001F920") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - cowboy_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - cowboy_height) / 2 + 2 + 5, cowboy_width, cowboy_height, - cowboy); - } else if (strcmp(msg, "\U0001F42D") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - deadmau5_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - deadmau5_height) / 2 + 2 + 5, deadmau5_width, deadmau5_height, - deadmau5); - } else if (strcmp(msg, "\xE2\x98\x80\xEF\xB8\x8F") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - sun_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - sun_height) / 2 + 2 + 5, - sun_width, sun_height, sun); - } else if (strcmp(msg, "\u2614") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - rain_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - rain_height) / 2 + 2 + 10, - rain_width, rain_height, rain); - } else if (strcmp(msg, "☁️") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - cloud_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - cloud_height) / 2 + 2 + 5, cloud_width, cloud_height, cloud); - } else if (strcmp(msg, "🌫️") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - fog_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - fog_height) / 2 + 2 + 5, - fog_width, fog_height, fog); - } else if (strcmp(msg, "\U0001F608") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - devil_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - devil_height) / 2 + 2 + 5, devil_width, devil_height, devil); - } else if (strcmp(msg, "♥️") == 0 || strcmp(msg, "\U0001F9E1") == 0 || strcmp(msg, "\U00002763") == 0 || - strcmp(msg, "\U00002764") == 0 || strcmp(msg, "\U0001F495") == 0 || strcmp(msg, "\U0001F496") == 0 || - strcmp(msg, "\U0001F497") == 0 || strcmp(msg, "\U0001F498") == 0) { - display->drawXbm(x + (SCREEN_WIDTH - heart_width) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - heart_height) / 2 + 2 + 5, heart_width, heart_height, heart); - } else { - snprintf(tempBuf, sizeof(tempBuf), "%s", mp.decoded.payload.bytes); - display->drawStringMaxWidth(0 + x, 0 + y + FONT_HEIGHT_SMALL, x + display->getWidth(), tempBuf); - } -#else - snprintf(tempBuf, sizeof(tempBuf), "%s", mp.decoded.payload.bytes); - display->drawStringMaxWidth(0 + x, 0 + y + FONT_HEIGHT_SMALL, x + display->getWidth(), tempBuf); -#endif -} - -/// Draw a series of fields in a column, wrapping to multiple columns if needed -void Screen::drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields) -{ - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - const char **f = fields; - int xo = x, yo = y; - while (*f) { - display->drawString(xo, yo, *f); - if ((display->getColor() == BLACK) && config.display.heading_bold) - display->drawString(xo + 1, yo, *f); - - display->setColor(WHITE); - yo += FONT_HEIGHT_SMALL; - if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) { - xo += SCREEN_WIDTH / 2; - yo = 0; - } - f++; - } -} - -// Draw nodes status -static void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const NodeStatus *nodeStatus) -{ - char usersString[20]; - snprintf(usersString, sizeof(usersString), "%d/%d", nodeStatus->getNumOnline(), nodeStatus->getNumTotal()); -#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ - defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS)) && \ - !defined(DISPLAY_FORCE_SMALL_FONTS) - display->drawFastImage(x, y + 3, 8, 8, imgUser); -#else - display->drawFastImage(x, y, 8, 8, imgUser); -#endif - display->drawString(x + 10, y - 2, usersString); - if (config.display.heading_bold) - display->drawString(x + 11, y - 2, usersString); -} -#if HAS_GPS -// Draw GPS status summary -static void drawGPS(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) -{ - if (config.position.fixed_position) { - // GPS coordinates are currently fixed - display->drawString(x - 1, y - 2, "Fixed GPS"); - if (config.display.heading_bold) - display->drawString(x, y - 2, "Fixed GPS"); - return; - } - if (!gps->getIsConnected()) { - display->drawString(x, y - 2, "No GPS"); - if (config.display.heading_bold) - display->drawString(x + 1, y - 2, "No GPS"); - return; - } - display->drawFastImage(x, y, 6, 8, gps->getHasLock() ? imgPositionSolid : imgPositionEmpty); - if (!gps->getHasLock()) { - display->drawString(x + 8, y - 2, "No sats"); - if (config.display.heading_bold) - display->drawString(x + 9, y - 2, "No sats"); - return; - } else { - char satsString[3]; - uint8_t bar[2] = {0}; - - // Draw DOP signal bars - for (int i = 0; i < 5; i++) { - if (gps->getDOP() <= dopThresholds[i]) - bar[0] = ~((1 << (5 - i)) - 1); - else - bar[0] = 0b10000000; - // bar[1] = bar[0]; - display->drawFastImage(x + 9 + (i * 2), y, 2, 8, bar); - } - - // Draw satellite image - display->drawFastImage(x + 24, y, 8, 8, imgSatellite); - - // Draw the number of satellites - snprintf(satsString, sizeof(satsString), "%u", gps->getNumSatellites()); - display->drawString(x + 34, y - 2, satsString); - if (config.display.heading_bold) - display->drawString(x + 35, y - 2, satsString); - } -} - -// Draw status when GPS is disabled or not present -static void drawGPSpowerstat(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) -{ - String displayLine; - int pos; - if (y < FONT_HEIGHT_SMALL) { // Line 1: use short string - displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; - pos = SCREEN_WIDTH - display->getStringWidth(displayLine); - } else { - displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "GPS not present" - : "GPS is disabled"; - pos = (SCREEN_WIDTH - display->getStringWidth(displayLine)) / 2; - } - display->drawString(x + pos, y, displayLine); -} - -static void drawGPSAltitude(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) -{ - String displayLine = ""; - if (!gps->getIsConnected() && !config.position.fixed_position) { - // displayLine = "No GPS Module"; - // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } else if (!gps->getHasLock() && !config.position.fixed_position) { - // displayLine = "No GPS Lock"; - // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } else { - geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); - displayLine = "Altitude: " + String(geoCoord.getAltitude()) + "m"; - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) - displayLine = "Altitude: " + String(geoCoord.getAltitude() * METERS_TO_FEET) + "ft"; - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } -} - -// Draw GPS status coordinates -static void drawGPScoordinates(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) -{ - auto gpsFormat = config.display.gps_format; - String displayLine = ""; - - if (!gps->getIsConnected() && !config.position.fixed_position) { - displayLine = "No GPS present"; - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } else if (!gps->getHasLock() && !config.position.fixed_position) { - displayLine = "No GPS Lock"; - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } else { - - geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); - - if (gpsFormat != meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) { - char coordinateLine[22]; - if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DEC) { // Decimal Degrees - snprintf(coordinateLine, sizeof(coordinateLine), "%f %f", geoCoord.getLatitude() * 1e-7, - geoCoord.getLongitude() * 1e-7); - } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_UTM) { // Universal Transverse Mercator - snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %06u %07u", geoCoord.getUTMZone(), geoCoord.getUTMBand(), - geoCoord.getUTMEasting(), geoCoord.getUTMNorthing()); - } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_MGRS) { // Military Grid Reference System - snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %1c%1c %05u %05u", geoCoord.getMGRSZone(), - geoCoord.getMGRSBand(), geoCoord.getMGRSEast100k(), geoCoord.getMGRSNorth100k(), - geoCoord.getMGRSEasting(), geoCoord.getMGRSNorthing()); - } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OLC) { // Open Location Code - geoCoord.getOLCCode(coordinateLine); - } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OSGR) { // Ordnance Survey Grid Reference - if (geoCoord.getOSGRE100k() == 'I' || geoCoord.getOSGRN100k() == 'I') // OSGR is only valid around the UK region - snprintf(coordinateLine, sizeof(coordinateLine), "%s", "Out of Boundary"); - else - snprintf(coordinateLine, sizeof(coordinateLine), "%1c%1c %05u %05u", geoCoord.getOSGRE100k(), - geoCoord.getOSGRN100k(), geoCoord.getOSGREasting(), geoCoord.getOSGRNorthing()); - } - - // If fixed position, display text "Fixed GPS" alternating with the coordinates. - if (config.position.fixed_position) { - if ((millis() / 10000) % 2) { - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine); - } else { - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth("Fixed GPS"))) / 2, y, "Fixed GPS"); - } - } else { - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine); - } - } else { - char latLine[22]; - char lonLine[22]; - snprintf(latLine, sizeof(latLine), "%2i° %2i' %2u\" %1c", geoCoord.getDMSLatDeg(), geoCoord.getDMSLatMin(), - geoCoord.getDMSLatSec(), geoCoord.getDMSLatCP()); - snprintf(lonLine, sizeof(lonLine), "%3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(), geoCoord.getDMSLonMin(), - geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP()); - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(latLine))) / 2, y - FONT_HEIGHT_SMALL * 1, latLine); - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(lonLine))) / 2, y, lonLine); - } - } -} -#endif /** * Given a recent lat/lon return a guess of the heading the user is walking on. * @@ -1289,239 +213,10 @@ float Screen::estimatedHeading(double lat, double lon) /// We will skip one node - the one for us, so we just blindly loop over all /// nodes -static size_t nodeIndex; static int8_t prevFrame = -1; -// Draw the arrow pointing to a node's location -void Screen::drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian) -{ - Point tip(0.0f, 0.5f), tail(0.0f, -0.35f); // pointing up initially - float arrowOffsetX = 0.14f, arrowOffsetY = 1.0f; - Point leftArrow(tip.x - arrowOffsetX, tip.y - arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y - arrowOffsetY); - - Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow}; - - for (int i = 0; i < 4; i++) { - arrowPoints[i]->rotate(headingRadian); - arrowPoints[i]->scale(compassDiam * 0.6); - arrowPoints[i]->translate(compassX, compassY); - } - /* Old arrow - display->drawLine(tip.x, tip.y, tail.x, tail.y); - display->drawLine(leftArrow.x, leftArrow.y, tip.x, tip.y); - display->drawLine(rightArrow.x, rightArrow.y, tip.x, tip.y); - display->drawLine(leftArrow.x, leftArrow.y, tail.x, tail.y); - display->drawLine(rightArrow.x, rightArrow.y, tail.x, tail.y); - */ -#ifdef USE_EINK - display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); -#else - display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); -#endif - display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y); -} - -// Get a string representation of the time passed since something happened -void Screen::getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength) -{ - // Use an absolute timestamp in some cases. - // Particularly useful with E-Ink displays. Static UI, fewer refreshes. - uint8_t timestampHours, timestampMinutes; - int32_t daysAgo; - bool useTimestamp = deltaToTimestamp(agoSecs, ×tampHours, ×tampMinutes, &daysAgo); - - if (agoSecs < 120) // last 2 mins? - snprintf(timeStr, maxLength, "%u seconds ago", agoSecs); - // -- if suitable for timestamp -- - else if (useTimestamp && agoSecs < 15 * SECONDS_IN_MINUTE) // Last 15 minutes - snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / SECONDS_IN_MINUTE); - else if (useTimestamp && daysAgo == 0) // Today - snprintf(timeStr, maxLength, "Last seen: %02u:%02u", (unsigned int)timestampHours, (unsigned int)timestampMinutes); - else if (useTimestamp && daysAgo == 1) // Yesterday - snprintf(timeStr, maxLength, "Seen yesterday"); - else if (useTimestamp && daysAgo > 1) // Last six months (capped by deltaToTimestamp method) - snprintf(timeStr, maxLength, "%li days ago", (long)daysAgo); - // -- if using time delta instead -- - else if (agoSecs < 120 * 60) // last 2 hrs - snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / 60); - // Only show hours ago if it's been less than 6 months. Otherwise, we may have bad data. - else if ((agoSecs / 60 / 60) < (hours_in_month * 6)) - snprintf(timeStr, maxLength, "%u hours ago", agoSecs / 60 / 60); - else - snprintf(timeStr, maxLength, "unknown age"); -} - -void Screen::drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading) -{ - // If north is supposed to be at the top of the compass we want rotation to be +0 - if (config.display.compass_north_top) - myHeading = -0; - /* N sign points currently not deleted*/ - Point N1(-0.04f, 0.65f), N2(0.04f, 0.65f); // N sign points (N1-N4) - Point N3(-0.04f, 0.55f), N4(0.04f, 0.55f); - Point NC1(0.00f, 0.50f); // north circle center point - Point *rosePoints[] = {&N1, &N2, &N3, &N4, &NC1}; - - uint16_t compassDiam = Screen::getCompassDiam(SCREEN_WIDTH, SCREEN_HEIGHT); - - for (int i = 0; i < 5; i++) { - // North on compass will be negative of heading - rosePoints[i]->rotate(-myHeading); - rosePoints[i]->scale(compassDiam); - rosePoints[i]->translate(compassX, compassY); - } - - /* changed the N sign to a small circle on the compass circle. - display->drawLine(N1.x, N1.y, N3.x, N3.y); - display->drawLine(N2.x, N2.y, N4.x, N4.y); - display->drawLine(N1.x, N1.y, N4.x, N4.y); - */ - display->drawCircle(NC1.x, NC1.y, 4); // North sign circle, 4px radius is sufficient for all displays. -} - -uint16_t Screen::getCompassDiam(uint32_t displayWidth, uint32_t displayHeight) -{ - uint16_t diam = 0; - uint16_t offset = 0; - - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) - offset = FONT_HEIGHT_SMALL; - - // get the smaller of the 2 dimensions and subtract 20 - if (displayWidth > (displayHeight - offset)) { - diam = displayHeight - offset; - // if 2/3 of the other size would be smaller, use that - if (diam > (displayWidth * 2 / 3)) { - diam = displayWidth * 2 / 3; - } - } else { - diam = displayWidth; - if (diam > ((displayHeight - offset) * 2 / 3)) { - diam = (displayHeight - offset) * 2 / 3; - } - } - - return diam - 20; -}; - -static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - // We only advance our nodeIndex if the frame # has changed - because - // drawNodeInfo will be called repeatedly while the frame is shown - if (state->currentFrame != prevFrame) { - prevFrame = state->currentFrame; - - nodeIndex = (nodeIndex + 1) % nodeDB->getNumMeshNodes(); - meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(nodeIndex); - if (n->num == nodeDB->getNodeNum()) { - // Don't show our node, just skip to next - nodeIndex = (nodeIndex + 1) % nodeDB->getNumMeshNodes(); - n = nodeDB->getMeshNodeByIndex(nodeIndex); - } - } - - meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(nodeIndex); - - display->setFont(FONT_SMALL); - - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); - } - - const char *username = node->has_user ? node->user.long_name : "Unknown Name"; - - static char signalStr[20]; - - // section here to choose whether to display hops away rather than signal strength if more than 0 hops away. - if (node->hops_away > 0) { - snprintf(signalStr, sizeof(signalStr), "Hops Away: %d", node->hops_away); - } else { - snprintf(signalStr, sizeof(signalStr), "Signal: %d%%", clamp((int)((node->snr + 10) * 5), 0, 100)); - } - - static char lastStr[20]; - screen->getTimeAgoStr(sinceLastSeen(node), lastStr, sizeof(lastStr)); - - static char distStr[20]; - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - strncpy(distStr, "? mi ?°", sizeof(distStr)); // might not have location data - } else { - strncpy(distStr, "? km ?°", sizeof(distStr)); - } - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - const char *fields[] = {username, lastStr, signalStr, distStr, NULL}; - int16_t compassX = 0, compassY = 0; - uint16_t compassDiam = Screen::getCompassDiam(SCREEN_WIDTH, SCREEN_HEIGHT); - - // coordinates for the center of the compass/circle - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - compassX = x + SCREEN_WIDTH - compassDiam / 2 - 5; - compassY = y + SCREEN_HEIGHT / 2; - } else { - compassX = x + SCREEN_WIDTH - compassDiam / 2 - 5; - compassY = y + FONT_HEIGHT_SMALL + (SCREEN_HEIGHT - FONT_HEIGHT_SMALL) / 2; - } - bool hasNodeHeading = false; - - if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading())) { - const meshtastic_PositionLite &op = ourNode->position; - float myHeading; - if (screen->hasHeading()) - myHeading = (screen->getHeading()) * PI / 180; // gotta convert compass degrees to Radians - else - myHeading = screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i)); - screen->drawCompassNorth(display, compassX, compassY, myHeading); - - if (nodeDB->hasValidPosition(node)) { - // display direction toward node - hasNodeHeading = true; - const meshtastic_PositionLite &p = node->position; - float d = - GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); - - float bearingToOther = - GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i)); - // If the top of the compass is a static north then bearingToOther can be drawn on the compass directly - // If the top of the compass is not a static north we need adjust bearingToOther based on heading - if (!config.display.compass_north_top) - bearingToOther -= myHeading; - screen->drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); - - float bearingToOtherDegrees = (bearingToOther < 0) ? bearingToOther + 2 * PI : bearingToOther; - bearingToOtherDegrees = bearingToOtherDegrees * 180 / PI; - - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - if (d < (2 * MILES_TO_FEET)) - snprintf(distStr, sizeof(distStr), "%.0fft %.0f°", d * METERS_TO_FEET, bearingToOtherDegrees); - else - snprintf(distStr, sizeof(distStr), "%.1fmi %.0f°", d * METERS_TO_FEET / MILES_TO_FEET, - bearingToOtherDegrees); - } else { - if (d < 2000) - snprintf(distStr, sizeof(distStr), "%.0fm %.0f°", d, bearingToOtherDegrees); - else - snprintf(distStr, sizeof(distStr), "%.1fkm %.0f°", d / 1000, bearingToOtherDegrees); - } - } - } - if (!hasNodeHeading) { - // direction to node is unknown so display question mark - // Debug info for gps lock errors - // LOG_DEBUG("ourNode %d, ourPos %d, theirPos %d", !!ourNode, ourNode && hasValidPosition(ourNode), - // hasValidPosition(node)); - display->drawString(compassX - FONT_HEIGHT_SMALL / 4, compassY - FONT_HEIGHT_SMALL / 2, "?"); - } - display->drawCircle(compassX, compassY, compassDiam / 2); - - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->setColor(BLACK); - } - // Must be after distStr is populated - screen->drawColumns(display, x, y, fields); -} +// Combined dynamic node list frame cycling through LastHeard, HopSignal, and Distance modes +// Uses a single frame and changes data every few seconds (E-Ink variant is separate) #if defined(ESP_PLATFORM) && defined(USE_ST7789) SPIClass SPI1(HSPI); @@ -1540,6 +235,7 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O ST7789_MISO, ST7789_SCK); #else dispdev = new ST7789Spi(&SPI1, ST7789_RESET, ST7789_RS, ST7789_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT); + static_cast(dispdev)->setRGB(COLOR565(255, 255, 128)); #endif #elif defined(USE_SSD1306) dispdev = new SSD1306Wire(address.address, -1, -1, geometry, @@ -1557,15 +253,17 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O #elif defined(USE_ST7567) dispdev = new ST7567Wire(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); -#elif ARCH_PORTDUINO && !HAS_TFT - if (settingsMap[displayPanel] != no_screen) { - LOG_DEBUG("Make TFTDisplay!"); - dispdev = new TFTDisplay(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); - } else { - dispdev = new AutoOLEDWire(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); - isAUTOOled = true; +#elif ARCH_PORTDUINO + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + if (settingsMap[displayPanel] != no_screen) { + LOG_DEBUG("Make TFTDisplay!"); + dispdev = new TFTDisplay(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); + } else { + dispdev = new AutoOLEDWire(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); + isAUTOOled = true; + } } #else dispdev = new AutoOLEDWire(address.address, -1, -1, geometry, @@ -1589,7 +287,7 @@ Screen::~Screen() void Screen::doDeepSleep() { #ifdef USE_EINK - setOn(false, drawDeepSleepScreen); + setOn(false, graphics::UIRenderer::drawDeepSleepFrame); #ifdef PIN_EINK_EN digitalWrite(PIN_EINK_EN, LOW); // power off backlight #endif @@ -1607,7 +305,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) if (on != screenOn) { if (on) { LOG_INFO("Turn on screen"); - buttonThread->setScreenFlag(true); powerMon->setState(meshtastic_PowerMon_State_Screen_On); #ifdef T_WATCH_S3 PMU->enablePowerOutput(XPOWERS_ALDO2); @@ -1651,8 +348,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) // eInkScreensaver parameter is usually NULL (default argument), default frame used instead setScreensaverFrames(einkScreensaver); #endif - LOG_INFO("Turn off screen"); - buttonThread->setScreenFlag(false); #ifdef ELECROW_ThinkNode_M1 if (digitalRead(PIN_EINK_EN) == HIGH) { digitalWrite(PIN_EINK_EN, LOW); @@ -1687,10 +382,10 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) void Screen::setup() { - // We don't set useDisplay until setup() is called, because some boards have a declaration of this object but the device - // is never found when probing i2c and therefore we don't call setup and never want to do (invalid) accesses to this device. + // === Enable display rendering === useDisplay = true; + // === Detect OLED subtype (if supported by board variant) === #ifdef AutoOLEDWire_h if (isAUTOOled) static_cast(dispdev)->setDetected(model); @@ -1701,66 +396,62 @@ void Screen::setup() #endif #if defined(USE_ST7789) && defined(TFT_MESH) - // Heltec T114 and T190: honor a custom text color, if defined in variant.h + // Apply custom RGB color (e.g. Heltec T114/T190) static_cast(dispdev)->setRGB(TFT_MESH); #endif - // Initialising the UI will init the display too. + // === Initialize display and UI system === ui->init(); - displayWidth = dispdev->width(); displayHeight = dispdev->height(); - ui->setTimePerTransition(0); + ui->setTimePerTransition(0); // Disable animation delays + ui->setIndicatorPosition(BOTTOM); // Not used (indicators disabled below) + ui->setIndicatorDirection(LEFT_RIGHT); // Not used (indicators disabled below) + ui->setFrameAnimation(SLIDE_LEFT); // Used only when indicators are active + ui->disableAllIndicators(); // Disable page indicator dots + ui->getUiState()->userData = this; // Allow static callbacks to access Screen instance - ui->setIndicatorPosition(BOTTOM); - // Defines where the first frame is located in the bar. - ui->setIndicatorDirection(LEFT_RIGHT); - ui->setFrameAnimation(SLIDE_LEFT); - // Don't show the page swipe dots while in boot screen. - ui->disableAllIndicators(); - // Store a pointer to Screen so we can get to it from static functions. - ui->getUiState()->userData = this; + // === Set custom overlay callbacks === + static OverlayCallback overlays[] = { + graphics::UIRenderer::drawFunctionOverlay, // For mute/buzzer modifiers etc. + graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame + }; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - // Set the utf8 conversion function + // === Enable UTF-8 to display mapping === dispdev->setFontTableLookupFunction(customFontTableLookup); #ifdef USERPREFS_OEM_TEXT - logo_timeout *= 2; // Double the time if we have a custom logo + logo_timeout *= 2; // Give more time for branded boot logos #endif - // Add frames. - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); - alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { + // === Configure alert frames (e.g., "Resuming..." or region name) === + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip slow refresh + alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { #ifdef ARCH_ESP32 - if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1) { - drawFrameText(display, state, x, y, "Resuming..."); - } else + if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1) + graphics::UIRenderer::drawFrameText(display, state, x, y, "Resuming..."); + else #endif { - // Draw region in upper left - const char *region = myRegion ? myRegion->name : NULL; - drawIconScreen(region, display, state, x, y); + const char *region = myRegion ? myRegion->name : nullptr; + graphics::UIRenderer::drawIconScreen(region, display, state, x, y); } }; ui->setFrames(alertFrames, 1); - // No overlays. - ui->setOverlays(nullptr, 0); + ui->disableAutoTransition(); // Require manual navigation between frames - // Require presses to switch between frames. - ui->disableAutoTransition(); - - // Set up a log buffer with 3 lines, 32 chars each. + // === Log buffer for on-screen logs (3 lines max) === dispdev->setLogBuffer(3, 32); + // === Optional screen mirroring or flipping (e.g. for T-Beam orientation) === #ifdef SCREEN_MIRROR dispdev->mirrorScreen(); #else - // Standard behaviour is to FLIP the screen (needed on T-Beam). If this config item is set, unflip it, and thereby logically - // flip it. If you have a headache now, you're welcome. if (!config.display.flip_screen) { -#if defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \ - defined(ST7789_CS) || defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) +#if defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \ + defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) static_cast(dispdev)->flipScreenVertically(); #elif defined(USE_ST7789) static_cast(dispdev)->flipScreenVertically(); @@ -1770,30 +461,30 @@ void Screen::setup() } #endif - // Get our hardware ID + // === Generate device ID from MAC address === uint8_t dmac[6]; getMacAddr(dmac); - snprintf(ourId, sizeof(ourId), "%02x%02x", dmac[4], dmac[5]); + snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]); + #if ARCH_PORTDUINO - handleSetOn(false); // force clean init + handleSetOn(false); // Ensure proper init for Arduino targets #endif - // Turn on the display. + // === Turn on display and trigger first draw === handleSetOn(true); - - // On some ssd1306 clones, the first draw command is discarded, so draw it - // twice initially. Skip this for EINK Displays to save a few seconds during boot ui->update(); #ifndef USE_EINK - ui->update(); + ui->update(); // Some SSD1306 clones drop the first draw, so run twice #endif serialSinceMsec = millis(); -#if ARCH_PORTDUINO && !HAS_TFT - if (settingsMap[touchscreenModule]) { - touchScreenImpl1 = - new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch); - touchScreenImpl1->init(); +#if ARCH_PORTDUINO + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + if (settingsMap[touchscreenModule]) { + touchScreenImpl1 = + new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch); + touchScreenImpl1->init(); + } } #elif HAS_TOUCHSCREEN touchScreenImpl1 = @@ -1801,10 +492,11 @@ void Screen::setup() touchScreenImpl1->init(); #endif - // Subscribe to status updates + // === Subscribe to device status updates === powerStatusObserver.observe(&powerStatus->onNewStatus); gpsStatusObserver.observe(&gpsStatus->onNewStatus); nodeStatusObserver.observe(&nodeStatus->onNewStatus); + #if !MESHTASTIC_EXCLUDE_ADMIN adminMessageObserver.observe(adminModule); #endif @@ -1813,7 +505,7 @@ void Screen::setup() if (inputBroker) inputObserver.observe(inputBroker); - // Modules can notify screen about refresh + // === Notify modules that support UI events === MeshModule::observeUIEvents(&uiFrameEventObserver); } @@ -1881,7 +573,7 @@ int32_t Screen::runOnce() if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) { LOG_INFO("Switch to OEM screen..."); // Change frames. - static FrameCallback bootOEMFrames[] = {drawOEMBootScreen}; + static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen}; static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]); ui->setFrames(bootOEMFrames, bootOEMFrameCount); ui->update(); @@ -1893,10 +585,13 @@ int32_t Screen::runOnce() #endif #ifndef DISABLE_WELCOME_UNSET - if (showingNormalScreen && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - setWelcomeFrames(); + if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + LoraRegionPicker(0); } #endif + if (!NotificationRenderer::isOverlayBannerShowing() && rebootAtMsec != 0) { + showOverlayBanner("Rebooting...", 0); + } // Process incoming commands. for (;;) { @@ -1923,6 +618,7 @@ int32_t Screen::runOnce() case Cmd::START_ALERT_FRAME: { showingBootScreen = false; // this should avoid the edge case where an alert triggers before the boot screen goes away showingNormalScreen = false; + NotificationRenderer::pauseBanner = true; alertFrames[0] = alertFrame; #ifdef USE_EINK EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please @@ -1936,14 +632,11 @@ int32_t Screen::runOnce() handleStartFirmwareUpdateScreen(); break; case Cmd::STOP_ALERT_FRAME: + NotificationRenderer::pauseBanner = false; case Cmd::STOP_BOOT_SCREEN: EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame setFrames(); break; - case Cmd::PRINT: - handlePrint(cmd.print_text); - free(cmd.print_text); - break; default: LOG_ERROR("Invalid screen cmd"); } @@ -1962,6 +655,7 @@ int32_t Screen::runOnce() // Switch to a low framerate (to save CPU) when we are not in transition // but we should only call setTargetFPS when framestate changes, because // otherwise that breaks animations. + if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) { // oldFrameState = ui->getUiState()->frameState; targetFramerate = IDLE_FRAMERATE; @@ -1977,8 +671,8 @@ int32_t Screen::runOnce() if (config.display.auto_screen_carousel_secs > 0 && !Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) { -// If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead -// Carousel is potentially a major source of E-Ink display wear + // If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead + // Carousel is potentially a major source of E-Ink display wear #if !defined(EINK_BACKGROUND_USES_FAST) EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); #endif @@ -1996,47 +690,18 @@ int32_t Screen::runOnce() return (1000 / targetFramerate); } -void Screen::drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - Screen *screen2 = reinterpret_cast(state->userData); - screen2->debugInfo.drawFrame(display, state, x, y); -} - -void Screen::drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - Screen *screen2 = reinterpret_cast(state->userData); - screen2->debugInfo.drawFrameSettings(display, state, x, y); -} - -void Screen::drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - Screen *screen2 = reinterpret_cast(state->userData); - screen2->debugInfo.drawFrameWiFi(display, state, x, y); -} - /* show a message that the SSL cert is being built * it is expected that this will be used during the boot phase */ void Screen::setSSLFrames() { if (address_found.address) { // LOG_DEBUG("Show SSL frames"); - static FrameCallback sslFrames[] = {drawSSLScreen}; + static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen}; ui->setFrames(sslFrames, 1); ui->update(); } } -/* show a message that the SSL cert is being built - * it is expected that this will be used during the boot phase */ -void Screen::setWelcomeFrames() -{ - if (address_found.address) { - // LOG_DEBUG("Show Welcome frames"); - static FrameCallback frames[] = {drawWelcomeScreen}; - setFrameImmediateDraw(frames); - } -} - #ifdef USE_EINK /// Determine which screensaver frame to use, then set the FrameCallback void Screen::setScreensaverFrames(FrameCallback einkScreensaver) @@ -2059,7 +724,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) // Else, display the usual "overlay" screensaver else { - screensaverOverlay = drawScreensaverOverlay; + screensaverOverlay = graphics::UIRenderer::drawScreensaverOverlay; ui->setOverlays(&screensaverOverlay, 1); } @@ -2095,33 +760,17 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) void Screen::setFrames(FrameFocus focus) { uint8_t originalPosition = ui->getUiState()->currentFrame; + uint8_t previousFrameCount = framesetInfo.frameCount; FramesetInfo fsi; // Location of specific frames, for applying focus parameter LOG_DEBUG("Show standard frames"); showingNormalScreen = true; -#ifdef USE_EINK - // If user has disabled the screensaver, warn them after boot - static bool warnedScreensaverDisabled = false; - if (config.display.screen_on_secs == 0 && !warnedScreensaverDisabled) { - screen->print("Screensaver disabled\n"); - warnedScreensaverDisabled = true; - } -#endif - - moduleFrames = MeshModule::GetMeshModulesWithUIFrames(); - LOG_DEBUG("Show %d module frames", moduleFrames.size()); -#ifdef DEBUG_PORT - int totalFrameCount = MAX_NUM_NODES + NUM_EXTRA_FRAMES + moduleFrames.size(); - LOG_DEBUG("Total frame count: %d", totalFrameCount); -#endif - - // We don't show the node info of our node (if we have it yet - we should) - size_t numMeshNodes = nodeDB->getNumMeshNodes(); - if (numMeshNodes > 0) - numMeshNodes--; + indicatorIcons.clear(); size_t numframes = 0; + moduleFrames = MeshModule::GetMeshModulesWithUIFrames(); + LOG_DEBUG("Show %d module frames", moduleFrames.size()); // put all of the module frames first. // this is a little bit of a dirty hack; since we're going to call @@ -2136,14 +785,12 @@ void Screen::setFrames(FrameFocus focus) // Check if the module being drawn has requested focus // We will honor this request later, if setFrames was triggered by a UIFrameEvent MeshModule *m = *i; - if (m->isRequestingFocus()) { + if (m->isRequestingFocus()) fsi.positions.focusedModule = numframes; - } - - // Identify the position of specific modules, if we need to know this later if (m == waypointModule) fsi.positions.waypoint = numframes; + indicatorIcons.push_back(icon_module); numframes++; } @@ -2152,55 +799,103 @@ void Screen::setFrames(FrameFocus focus) // If we have a critical fault, show it first fsi.positions.fault = numframes; if (error_code) { - normalFrames[numframes++] = drawCriticalFaultFrame; + normalFrames[numframes++] = NotificationRenderer::drawCriticalFaultFrame; + indicatorIcons.push_back(icon_error); focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame } #if defined(DISPLAY_CLOCK_FRAME) - normalFrames[numframes++] = screen->digitalWatchFace ? &Screen::drawDigitalClockFrame : &Screen::drawAnalogClockFrame; + fsi.positions.clock = numframes; + normalFrames[numframes++] = graphics::ClockRenderer::digitalWatchFace ? graphics::ClockRenderer::drawDigitalClockFrame + : &graphics::ClockRenderer::drawAnalogClockFrame; + indicatorIcons.push_back(icon_clock); #endif - // If we have a text message - show it next, unless it's a phone message and we aren't using any special modules - if (devicestate.has_rx_text_message && shouldDrawMessage(&devicestate.rx_text_message)) { - fsi.positions.textMessage = numframes; - normalFrames[numframes++] = drawTextMessageFrame; + // Declare this early so it’s available in FOCUS_PRESERVE block + bool willInsertTextMessage = shouldDrawMessage(&devicestate.rx_text_message); + + fsi.positions.home = numframes; + normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused; + indicatorIcons.push_back(icon_home); + + fsi.positions.textMessage = numframes; + normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame; + indicatorIcons.push_back(icon_mail); + +#ifndef USE_EINK + normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicNodeListScreen; + indicatorIcons.push_back(icon_nodes); +#endif + +// Show detailed node views only on E-Ink builds +#ifdef USE_EINK + normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen; + indicatorIcons.push_back(icon_nodes); + + normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen; + indicatorIcons.push_back(icon_signal); + + normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen; + indicatorIcons.push_back(icon_distance); +#endif +#if HAS_GPS + normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; + indicatorIcons.push_back(icon_list); + + fsi.positions.gps = numframes; + normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen; + indicatorIcons.push_back(icon_compass); +#endif + if (RadioLibInterface::instance) { + fsi.positions.lora = numframes; + normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused; + indicatorIcons.push_back(icon_radio); + } + if (!dismissedFrames.memory) { + fsi.positions.memory = numframes; + normalFrames[numframes++] = graphics::DebugRenderer::drawMemoryUsage; + indicatorIcons.push_back(icon_memory); + } +#if !defined(DISPLAY_CLOCK_FRAME) + fsi.positions.clock = numframes; + normalFrames[numframes++] = graphics::ClockRenderer::drawDigitalClockFrame; + indicatorIcons.push_back(icon_clock); +#endif + + // We don't show the node info of our node (if we have it yet - we should) + size_t numMeshNodes = nodeDB->getNumMeshNodes(); + if (numMeshNodes > 0) + numMeshNodes--; + + for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); + if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) { + if (fsi.positions.firstFavorite == 255) + fsi.positions.firstFavorite = numframes; + fsi.positions.lastFavorite = numframes; + normalFrames[numframes++] = graphics::UIRenderer::drawNodeInfo; + indicatorIcons.push_back(icon_node); + } } - // then all the nodes - // We only show a few nodes in our scrolling list - because meshes with many nodes would have too many screens - size_t numToShow = min(numMeshNodes, 4U); - for (size_t i = 0; i < numToShow; i++) - normalFrames[numframes++] = drawNodeInfo; - - // then the debug info - // - // Since frames are basic function pointers, we have to use a helper to - // call a method on debugInfo object. - fsi.positions.log = numframes; - normalFrames[numframes++] = &Screen::drawDebugInfoTrampoline; - - // call a method on debugInfoScreen object (for more details) - fsi.positions.settings = numframes; - normalFrames[numframes++] = &Screen::drawDebugInfoSettingsTrampoline; - - fsi.positions.wifi = numframes; #if HAS_WIFI && !defined(ARCH_PORTDUINO) - if (isWifiAvailable()) { - // call a method on debugInfoScreen object (for more details) - normalFrames[numframes++] = &Screen::drawDebugInfoWiFiTrampoline; + if (!dismissedFrames.wifi && isWifiAvailable()) { + fsi.positions.wifi = numframes; + normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoWiFiTrampoline; + indicatorIcons.push_back(icon_wifi); } #endif - fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE + fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE + this->frameCount = numframes; // ✅ Save frame count for use in custom overlay LOG_DEBUG("Finished build frames. numframes: %d", numframes); ui->setFrames(normalFrames, numframes); - ui->enableAllIndicators(); + ui->disableAllIndicators(); - // Add function overlay here. This can show when notifications muted, modifier key is active etc - static OverlayCallback functionOverlay[] = {drawFunctionOverlay}; - static const int functionOverlayCount = sizeof(functionOverlay) / sizeof(functionOverlay[0]); - ui->setOverlays(functionOverlay, functionOverlayCount); + // Add overlays: frame icons and alert banner) + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawAlertBannerOverlay}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list // just changed) @@ -2208,12 +903,13 @@ void Screen::setFrames(FrameFocus focus) // Focus on a specific frame, in the frame set we just created switch (focus) { case FOCUS_DEFAULT: - ui->switchToFrame(0); // First frame + ui->switchToFrame(fsi.positions.deviceFocused); break; case FOCUS_FAULT: ui->switchToFrame(fsi.positions.fault); break; case FOCUS_TEXTMESSAGE: + hasUnreadMessage = false; // ✅ Clear when message is *viewed* ui->switchToFrame(fsi.positions.textMessage); break; case FOCUS_MODULE: @@ -2223,31 +919,14 @@ void Screen::setFrames(FrameFocus focus) break; case FOCUS_PRESERVE: - // If we can identify which type of frame "originalPosition" was, can move directly to it in the new frameset - const FramesetInfo &oldFsi = this->framesetInfo; - if (originalPosition == oldFsi.positions.log) - ui->switchToFrame(fsi.positions.log); - else if (originalPosition == oldFsi.positions.settings) - ui->switchToFrame(fsi.positions.settings); - else if (originalPosition == oldFsi.positions.wifi) - ui->switchToFrame(fsi.positions.wifi); - - // If frame count has decreased - else if (fsi.frameCount < oldFsi.frameCount) { - uint8_t numDropped = oldFsi.frameCount - fsi.frameCount; - // Move n frames backwards - if (numDropped <= originalPosition) - ui->switchToFrame(originalPosition - numDropped); - // Unless that would put us "out of bounds" (< 0) - else - ui->switchToFrame(0); - } - - // If we're not sure exactly which frame we were on, at least return to the same frame number - // (node frames; module frames) - else + // No more adjustment — force stay on same index + if (previousFrameCount > fsi.frameCount) { + ui->switchToFrame(originalPosition - 1); + } else if (previousFrameCount < fsi.frameCount) { + ui->switchToFrame(originalPosition + 1); + } else { ui->switchToFrame(originalPosition); - + } break; } @@ -2275,18 +954,25 @@ void Screen::dismissCurrentFrame() if (currentFrame == framesetInfo.positions.textMessage && devicestate.has_rx_text_message) { LOG_INFO("Dismiss Text Message"); devicestate.has_rx_text_message = false; - dismissed = true; - } - - else if (currentFrame == framesetInfo.positions.waypoint && devicestate.has_rx_waypoint) { + memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message)); + } else if (currentFrame == framesetInfo.positions.waypoint && devicestate.has_rx_waypoint) { LOG_DEBUG("Dismiss Waypoint"); devicestate.has_rx_waypoint = false; + dismissedFrames.waypoint = true; + dismissed = true; + } else if (currentFrame == framesetInfo.positions.wifi) { + LOG_DEBUG("Dismiss WiFi Screen"); + dismissedFrames.wifi = true; + dismissed = true; + } else if (currentFrame == framesetInfo.positions.memory) { + LOG_INFO("Dismiss Memory"); + dismissedFrames.memory = true; dismissed = true; } - // If we did make changes to dismiss, we now need to regenerate the frameset - if (dismissed) - setFrames(); + if (dismissed) { + setFrames(FOCUS_DEFAULT); // You could also use FOCUS_PRESERVE + } } void Screen::handleStartFirmwareUpdateScreen() @@ -2295,7 +981,7 @@ void Screen::handleStartFirmwareUpdateScreen() showingNormalScreen = false; EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // E-Ink: Explicitly use fast-refresh for next frame - static FrameCallback frames[] = {drawFrameFirmware}; + static FrameCallback frames[] = {graphics::NotificationRenderer::drawFrameFirmware}; setFrameImmediateDraw(frames); } @@ -2362,41 +1048,8 @@ void Screen::removeFunctionSymbol(std::string sym) setFastFramerate(); } -std::string Screen::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds) -{ - std::string uptime; - - if (days > (hours_in_month * 6)) - uptime = "?"; - else if (days >= 2) - uptime = std::to_string(days) + "d"; - else if (hours >= 2) - uptime = std::to_string(hours) + "h"; - else if (minutes >= 1) - uptime = std::to_string(minutes) + "m"; - else - uptime = std::to_string(seconds) + "s"; - return uptime; -} - -void Screen::handlePrint(const char *text) -{ - // the string passed into us probably has a newline, but that would confuse the logging system - // so strip it - LOG_DEBUG("Screen: %.*s", strlen(text) - 1, text); - if (!useDisplay || !showingNormalScreen) - return; - - dispdev->print(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) { @@ -2442,321 +1095,6 @@ void Screen::setFastFramerate() runASAP = true; } -void DebugInfo::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setFont(FONT_SMALL); - - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); - display->setColor(BLACK); - } - - char channelStr[20]; - { - concurrency::LockGuard guard(&lock); - snprintf(channelStr, sizeof(channelStr), "#%s", channels.getName(channels.getPrimaryIndex())); - } - - // Display power status - if (powerStatus->getHasBattery()) { - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - drawBattery(display, x, y + 2, imgBattery, powerStatus); - } else { - drawBattery(display, x + 1, y + 3, imgBattery, powerStatus); - } - } else if (powerStatus->knowsUSB()) { - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - display->drawFastImage(x, y + 2, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); - } else { - display->drawFastImage(x + 1, y + 3, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); - } - } - // Display nodes status - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 2, nodeStatus); - } else { - drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 3, nodeStatus); - } -#if HAS_GPS - // Display GPS status - if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - drawGPSpowerstat(display, x, y + 2, gpsStatus); - } else { - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - drawGPS(display, x + (SCREEN_WIDTH * 0.63), y + 2, gpsStatus); - } else { - drawGPS(display, x + (SCREEN_WIDTH * 0.63), y + 3, gpsStatus); - } - } -#endif - display->setColor(WHITE); - // Draw the channel name - display->drawString(x, y + FONT_HEIGHT_SMALL, channelStr); - // Draw our hardware ID to assist with bluetooth pairing. Either prefix with Info or S&F Logo - if (moduleConfig.store_forward.enabled) { -#ifdef ARCH_ESP32 - if (!Throttle::isWithinTimespanMs(storeForwardModule->lastHeartbeat, - (storeForwardModule->heartbeatInterval * 1200))) { // no heartbeat, overlap a bit -#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ - defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || ARCH_PORTDUINO) && \ - !defined(DISPLAY_FORCE_SMALL_FONTS) - display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8, - imgQuestionL1); - display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8, - imgQuestionL2); -#else - display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8, - imgQuestion); -#endif - } else { -#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ - defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS)) && \ - !defined(DISPLAY_FORCE_SMALL_FONTS) - display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 16, 8, - imgSFL1); - display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 16, 8, - imgSFL2); -#else - display->drawFastImage(x + SCREEN_WIDTH - 13 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 11, 8, - imgSF); -#endif - } -#endif - } else { - // TODO: Raspberry Pi supports more than just the one screen size -#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ - defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || ARCH_PORTDUINO) && \ - !defined(DISPLAY_FORCE_SMALL_FONTS) - display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8, - imgInfoL1); - display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8, - imgInfoL2); -#else - display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8, imgInfo); -#endif - } - - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(ourId), y + FONT_HEIGHT_SMALL, ourId); - - // Draw any log messages - display->drawLogBuffer(x, y + (FONT_HEIGHT_SMALL * 2)); - - /* Display a heartbeat pixel that blinks every time the frame is redrawn */ -#ifdef SHOW_REDRAWS - if (heartbeat) - display->setPixel(0, 0); - heartbeat = !heartbeat; -#endif -} - -// Jm -void DebugInfo::drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ -#if HAS_WIFI && !defined(ARCH_PORTDUINO) - const char *wifiName = config.network.wifi_ssid; - - display->setFont(FONT_SMALL); - - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); - display->setColor(BLACK); - } - - if (WiFi.status() != WL_CONNECTED) { - display->drawString(x, y, String("WiFi: Not Connected")); - if (config.display.heading_bold) - display->drawString(x + 1, y, String("WiFi: Not Connected")); - } else { - display->drawString(x, y, String("WiFi: Connected")); - if (config.display.heading_bold) - display->drawString(x + 1, y, String("WiFi: Connected")); - - display->drawString(x + SCREEN_WIDTH - display->getStringWidth("RSSI " + String(WiFi.RSSI())), y, - "RSSI " + String(WiFi.RSSI())); - if (config.display.heading_bold) { - display->drawString(x + SCREEN_WIDTH - display->getStringWidth("RSSI " + String(WiFi.RSSI())) - 1, y, - "RSSI " + String(WiFi.RSSI())); - } - } - - display->setColor(WHITE); - - /* - - WL_CONNECTED: assigned when connected to a WiFi network; - - WL_NO_SSID_AVAIL: assigned when no SSID are available; - - WL_CONNECT_FAILED: assigned when the connection fails for all the attempts; - - WL_CONNECTION_LOST: assigned when the connection is lost; - - WL_DISCONNECTED: assigned when disconnected from a network; - - WL_IDLE_STATUS: it is a temporary status assigned when WiFi.begin() is called and remains active until the number of - attempts expires (resulting in WL_CONNECT_FAILED) or a connection is established (resulting in WL_CONNECTED); - - WL_SCAN_COMPLETED: assigned when the scan networks is completed; - - WL_NO_SHIELD: assigned when no WiFi shield is present; - - */ - if (WiFi.status() == WL_CONNECTED) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "IP: " + String(WiFi.localIP().toString().c_str())); - } else if (WiFi.status() == WL_NO_SSID_AVAIL) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "SSID Not Found"); - } else if (WiFi.status() == WL_CONNECTION_LOST) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Connection Lost"); - } else if (WiFi.status() == WL_CONNECT_FAILED) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Connection Failed"); - } else if (WiFi.status() == WL_IDLE_STATUS) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Idle ... Reconnecting"); - } -#ifdef ARCH_ESP32 - else { - // Codes: - // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/wifi.html#wi-fi-reason-code - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, - WiFi.disconnectReasonName(static_cast(getWifiDisconnectReason()))); - } -#else - else { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Unkown status: " + String(WiFi.status())); - } -#endif - - display->drawString(x, y + FONT_HEIGHT_SMALL * 2, "SSID: " + String(wifiName)); - - display->drawString(x, y + FONT_HEIGHT_SMALL * 3, "http://meshtastic.local"); - - /* Display a heartbeat pixel that blinks every time the frame is redrawn */ -#ifdef SHOW_REDRAWS - if (heartbeat) - display->setPixel(0, 0); - heartbeat = !heartbeat; -#endif -#endif -} - -void DebugInfo::drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setFont(FONT_SMALL); - - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); - display->setColor(BLACK); - } - - char batStr[20]; - if (powerStatus->getHasBattery()) { - int batV = powerStatus->getBatteryVoltageMv() / 1000; - int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; - - snprintf(batStr, sizeof(batStr), "B %01d.%02dV %3d%% %c%c", batV, batCv, powerStatus->getBatteryChargePercent(), - powerStatus->getIsCharging() ? '+' : ' ', powerStatus->getHasUSB() ? 'U' : ' '); - - // Line 1 - display->drawString(x, y, batStr); - if (config.display.heading_bold) - display->drawString(x + 1, y, batStr); - } else { - // Line 1 - display->drawString(x, y, String("USB")); - if (config.display.heading_bold) - display->drawString(x + 1, y, String("USB")); - } - - // auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, true); - - // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode), y, mode); - // if (config.display.heading_bold) - // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode) - 1, y, mode); - - uint32_t currentMillis = millis(); - uint32_t seconds = currentMillis / 1000; - uint32_t minutes = seconds / 60; - uint32_t hours = minutes / 60; - uint32_t days = hours / 24; - // currentMillis %= 1000; - // seconds %= 60; - // minutes %= 60; - // hours %= 24; - - // Show uptime as days, hours, minutes OR seconds - std::string uptime = screen->drawTimeDelta(days, hours, minutes, seconds); - - // Line 1 (Still) - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); - if (config.display.heading_bold) - display->drawString(x - 1 + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); - - display->setColor(WHITE); - - // Setup string to assemble analogClock string - std::string analogClock = ""; - - uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone - if (rtc_sec > 0) { - long hms = rtc_sec % SEC_PER_DAY; - // hms += tz.tz_dsttime * SEC_PER_HOUR; - // hms -= tz.tz_minuteswest * SEC_PER_MIN; - // mod `hms` to ensure in positive range of [0...SEC_PER_DAY) - hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; - - // Tear apart hms into h:m:s - int hour = hms / SEC_PER_HOUR; - int min = (hms % SEC_PER_HOUR) / SEC_PER_MIN; - int sec = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN - - char timebuf[12]; - - if (config.display.use_12h_clock) { - std::string meridiem = "am"; - if (hour >= 12) { - if (hour > 12) - hour -= 12; - meridiem = "pm"; - } - if (hour == 00) { - hour = 12; - } - snprintf(timebuf, sizeof(timebuf), "%d:%02d:%02d%s", hour, min, sec, meridiem.c_str()); - } else { - snprintf(timebuf, sizeof(timebuf), "%02d:%02d:%02d", hour, min, sec); - } - analogClock += timebuf; - } - - // Line 2 - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str()); - - // Display Channel Utilization - char chUtil[13]; - snprintf(chUtil, sizeof(chUtil), "ChUtil %2.0f%%", airTime->channelUtilizationPercent()); - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(chUtil), y + FONT_HEIGHT_SMALL * 1, chUtil); - -#if HAS_GPS - if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - // Line 3 - if (config.display.gps_format != - meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) // if DMS then don't draw altitude - drawGPSAltitude(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); - - // Line 4 - drawGPScoordinates(display, x, y + FONT_HEIGHT_SMALL * 3, gpsStatus); - } else { - drawGPSpowerstat(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); - } -#endif -/* Display a heartbeat pixel that blinks every time the frame is redrawn */ -#ifdef SHOW_REDRAWS - if (heartbeat) - display->setPixel(0, 0); - heartbeat = !heartbeat; -#endif -} - int Screen::handleStatusUpdate(const meshtastic::Status *arg) { // LOG_DEBUG("Screen got status update %d", arg->getStatusType()); @@ -2772,16 +1110,58 @@ int Screen::handleStatusUpdate(const meshtastic::Status *arg) return 0; } +// Handles when message is received; will jump to text message frame. int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) { if (showingNormalScreen) { - // Outgoing message - if (packet->from == 0) - setFrames(FOCUS_PRESERVE); // Return to same frame (quietly hiding the rx text message frame) + if (packet->from == 0) { + // Outgoing message (likely sent from phone) + devicestate.has_rx_text_message = false; + memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message)); + dismissedFrames.textMessage = true; + hasUnreadMessage = false; // Clear unread state when user replies - // Incoming message - else - setFrames(FOCUS_TEXTMESSAGE); // Focus on the new message + setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list + } else { + // Incoming message + devicestate.has_rx_text_message = true; // Needed to include the message frame + hasUnreadMessage = true; // Enables mail icon in the header + setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view + forceDisplay(); // Forces screen redraw + + // === Prepare banner content === + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from); + const char *longName = (node && node->has_user) ? node->user.long_name : nullptr; + + const char *msgRaw = reinterpret_cast(packet->decoded.payload.bytes); + + char banner[256]; + + // Check for bell character in message to determine alert type + bool isAlert = false; + for (size_t i = 0; i < packet->decoded.payload.size && i < 100; i++) { + if (msgRaw[i] == '\x07') { + isAlert = true; + break; + } + } + + if (isAlert) { + if (longName && longName[0]) { + snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName); + } else { + strcpy(banner, "Alert Received"); + } + } else { + if (longName && longName[0]) { + snprintf(banner, sizeof(banner), "New Message from\n%s", longName); + } else { + strcpy(banner, "New Message"); + } + } + + screen->showOverlayBanner(banner, 3000); + } } return 0; @@ -2809,20 +1189,39 @@ int Screen::handleUIFrameEvent(const UIFrameEvent *event) int Screen::handleInputEvent(const InputEvent *event) { + if (!screenOn) + return 0; -#if defined(DISPLAY_CLOCK_FRAME) - // For the T-Watch, intercept touches to the 'toggle digital/analog watch face' button - uint8_t watchFaceFrame = error_code ? 1 : 0; - - if (this->ui->getUiState()->currentFrame == watchFaceFrame && event->touchX >= 204 && event->touchX <= 240 && - event->touchY >= 204 && event->touchY <= 240) { - screen->digitalWatchFace = !screen->digitalWatchFace; - - setFrames(); - +#ifdef USE_EINK // the screen is the last input handler, so if an event makes it here, we can assume it will prompt a screen draw. + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please + EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update + handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?) + setFastFramerate(); // Draw ASAP +#endif + if (NotificationRenderer::isOverlayBannerShowing()) { + NotificationRenderer::inEvent = event->inputEvent; + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, + NotificationRenderer::drawAlertBannerOverlay}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + setFastFramerate(); // Draw ASAP + ui->update(); return 0; } -#endif + /* + #if defined(DISPLAY_CLOCK_FRAME) + // For the T-Watch, intercept touches to the 'toggle digital/analog watch face' button + uint8_t watchFaceFrame = error_code ? 1 : 0; + + if (this->ui->getUiState()->currentFrame == watchFaceFrame && event->touchX >= 204 && event->touchX <= 240 && + event->touchY >= 204 && event->touchY <= 240) { + screen->digitalWatchFace = !screen->digitalWatchFace; + + setFrames(); + + return 0; + } + #endif + */ // Use left or right input from a keyboard to move between frames, // so long as a mesh module isn't using these events for some other purpose @@ -2837,10 +1236,140 @@ int Screen::handleInputEvent(const InputEvent *event) // If no modules are using the input, move between frames if (!inputIntercepted) { - if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) + if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_ALT_PRESS) { showPrevFrame(); - else if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT)) + } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS) { showNextFrame(); + } else if (event->inputEvent == INPUT_BROKER_SELECT) { + if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { + const char *banner_message; + int options; + if (kb_found) { + banner_message = "Action?\nBack\nSleep Screen\nNew Preset Msg\nNew Freetext Msg"; + options = 4; + } else { + banner_message = "Action?\nBack\nSleep Screen\nNew Preset Msg"; + options = 3; + } + showOverlayBanner(banner_message, 30000, options, [](int selected) -> void { + if (selected == 1) { + screen->setOn(false); + } else if (selected == 2) { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); + } else if (selected == 3) { + cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST); + } + }); +#if HAS_TFT + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.memory) { + showOverlayBanner("Switch to MUI?\nYes\nNo", 30000, 2, [](int selected) -> void { + if (selected == 0) { + config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR; + config.bluetooth.enabled = false; + service->reloadConfig(SEGMENT_CONFIG); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + } + }); +#else + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.memory) { + showOverlayBanner( + "Beeps Mode\nAll Enabled\nDisabled\nNotifications\nSystem Only", 30000, 4, + [](int selected) -> void { + config.device.buzzer_mode = (meshtastic_Config_DeviceConfig_BuzzerMode)selected; + service->reloadConfig(SEGMENT_CONFIG); + }, + config.device.buzzer_mode); +#endif +#if HAS_GPS + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.gps && gps) { + showOverlayBanner( + "Toggle GPS\nBack\nEnabled\nDisabled", 30000, 3, + [](int selected) -> void { + if (selected == 1) { + config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED; + playGPSEnableBeep(); + gps->enable(); + } else if (selected == 2) { + config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED; + playGPSDisableBeep(); + gps->disable(); + } + service->reloadConfig(SEGMENT_CONFIG); + }, + config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED ? 1 + : 2); // set inital selection +#endif + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.clock) { + TZPicker(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) { + LoraRegionPicker(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && + devicestate.rx_text_message.from) { + const char *banner_message; + int options; + if (kb_found) { + banner_message = "Message Action?\nBack\nDismiss\nReply via Preset\nReply via Freetext"; + options = 4; + } else { + banner_message = "Message Action?\nBack\nDismiss\nReply via Preset"; + options = 3; + } +#ifdef HAS_I2S + banner_message = "Message Action?\nBack\nDismiss\nReply via Preset\nReply via Freetext\nRead Aloud"; + options = 5; +#endif + showOverlayBanner(banner_message, 30000, options, [](int selected) -> void { + if (selected == 1) { + screen->dismissCurrentFrame(); + } else if (selected == 2) { + if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, + devicestate.rx_text_message.channel); + } else { + cannedMessageModule->LaunchWithDestination(devicestate.rx_text_message.from); + } + } else if (selected == 3) { + if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { + cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, + devicestate.rx_text_message.channel); + } else { + cannedMessageModule->LaunchFreetextWithDestination(devicestate.rx_text_message.from); + } + } +#ifdef HAS_I2S + else if (selected == 4) { + const meshtastic_MeshPacket &mp = devicestate.rx_text_message; + const char *msg = reinterpret_cast(mp.decoded.payload.bytes); + + audioThread->readAloud(msg); + } +#endif + }); + } else if (framesetInfo.positions.firstFavorite != 255 && + this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && + this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { + const char *banner_message; + int options; + if (kb_found) { + banner_message = "Message Node?\nCancel\nNew Preset Msg\nNew Freetext Msg"; + options = 3; + } else { + banner_message = "Message Node?\nCancel\nConfirm"; + options = 2; + } + showOverlayBanner(banner_message, 30000, options, [](int selected) -> void { + if (selected == 1) { + cannedMessageModule->LaunchWithDestination(graphics::UIRenderer::currentFavoriteNodeNum); + } else if (selected == 2) { + cannedMessageModule->LaunchFreetextWithDestination(graphics::UIRenderer::currentFavoriteNodeNum); + } + }); + } + } else if (event->inputEvent == INPUT_BROKER_BACK) { + showPrevFrame(); + } else if (event->inputEvent == INPUT_BROKER_CANCEL) { + setOn(false); + } } } @@ -2862,7 +1391,102 @@ int Screen::handleAdminMessage(const meshtastic_AdminMessage *arg) return 0; } +bool Screen::isOverlayBannerShowing() +{ + return NotificationRenderer::isOverlayBannerShowing(); +} + +void Screen::LoraRegionPicker(uint32_t duration) +{ + showOverlayBanner( + "Set the LoRa " + "region\nBack\nUS\nEU_433\nEU_868\nCN\nJP\nANZ\nKR\nTW\nRU\nIN\nNZ_865\nTH\nLORA_24\nUA_433\nUA_868\nMY_433\nMY_" + "919\nSG_" + "923\nPH_433\nPH_868\nPH_915\nANZ_433", + duration, 23, + [](int selected) -> void { + if (selected != 0 && config.lora.region != _meshtastic_Config_LoRaConfig_RegionCode(selected)) { + config.lora.region = _meshtastic_Config_LoRaConfig_RegionCode(selected); + // This is needed as we wait til picking the LoRa region to generate keys for the first time. + if (!owner.is_licensed) { + bool keygenSuccess = false; + if (config.security.private_key.size == 32) { + // public key is derived from private, so this will always have the same result. + if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { + keygenSuccess = true; + } + } else { + LOG_INFO("Generate new PKI keys"); + crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); + keygenSuccess = true; + } + if (keygenSuccess) { + config.security.public_key.size = 32; + config.security.private_key.size = 32; + owner.public_key.size = 32; + memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); + } + } + config.lora.tx_enabled = true; + initRegion(); + if (myRegion->dutyCycle < 100) { + config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit + } + service->reloadConfig(SEGMENT_CONFIG); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + } + }, + 0); +} + +void Screen::TZPicker() +{ + showOverlayBanner( + "Pick " + "Timezone\nBack\nUS/Hawaii\nUS/Alaska\nUS/Pacific\nUS/Mountain\nUS/Central\nUS/Eastern\nUTC\nEU/Western\nEU/" + "Central\nEU/Eastern\nAsia/Kolkata\nAsia/Hong_Kong\nAU/AWST\nAU/ACST\nAU/AEST\nPacific/NZ", + 30000, 17, [](int selected) -> void { + if (selected == 1) { // Hawaii + strncpy(config.device.tzdef, "HST10", sizeof(config.device.tzdef)); + } else if (selected == 2) { // Alaska + strncpy(config.device.tzdef, "AKST9AKDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 3) { // Pacific + strncpy(config.device.tzdef, "PST8PDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 4) { // Mountain + strncpy(config.device.tzdef, "MST7MDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 5) { // Central + strncpy(config.device.tzdef, "CST6CDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 6) { // Eastern + strncpy(config.device.tzdef, "EST5EDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 7) { // UTC + strncpy(config.device.tzdef, "UTC", sizeof(config.device.tzdef)); + } else if (selected == 8) { // EU/Western + strncpy(config.device.tzdef, "GMT0BST,M3.5.0/1,M10.5.0", sizeof(config.device.tzdef)); + } else if (selected == 9) { // EU/Central + strncpy(config.device.tzdef, "CET-1CEST,M3.5.0,M10.5.0/3", sizeof(config.device.tzdef)); + } else if (selected == 10) { // EU/Eastern + strncpy(config.device.tzdef, "EET-2EEST,M3.5.0/3,M10.5.0/4", sizeof(config.device.tzdef)); + } else if (selected == 11) { // Asia/Kolkata + strncpy(config.device.tzdef, "IST-5:30", sizeof(config.device.tzdef)); + } else if (selected == 12) { // China + strncpy(config.device.tzdef, "HKT-8", sizeof(config.device.tzdef)); + } else if (selected == 13) { // AU/AWST + strncpy(config.device.tzdef, "AWST-8", sizeof(config.device.tzdef)); + } else if (selected == 14) { // AU/ACST + strncpy(config.device.tzdef, "ACST-9:30ACDT,M10.1.0,M4.1.0/3", sizeof(config.device.tzdef)); + } else if (selected == 15) { // AU/AEST + strncpy(config.device.tzdef, "AEST-10AEDT,M10.1.0,M4.1.0/3", sizeof(config.device.tzdef)); + } else if (selected == 16) { // NZ + strncpy(config.device.tzdef, "NZST-12NZDT,M9.5.0,M4.1.0/3", sizeof(config.device.tzdef)); + } + + setenv("TZ", config.device.tzdef, 1); + service->reloadConfig(SEGMENT_CONFIG); + }); +} + } // namespace graphics + #else graphics::Screen::Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY) {} #endif // HAS_SCREEN diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index ce416156f..c264f0f07 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -5,6 +5,10 @@ #include "detect/ScanI2C.h" #include "mesh/generated/meshtastic/config.pb.h" #include +#include +#include + +#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) #if !HAS_SCREEN #include "power.h" @@ -14,11 +18,18 @@ namespace graphics class Screen { public: + enum FrameFocus : uint8_t { + FOCUS_DEFAULT, // No specific frame + FOCUS_PRESERVE, // Return to the previous frame + FOCUS_FAULT, + FOCUS_TEXTMESSAGE, + FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus + }; + explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY); void onPress() {} void setup() {} void setOn(bool) {} - void print(const char *) {} void doDeepSleep() {} void forceDisplay(bool forceUiUpdate = false) {} void startFirmwareUpdateScreen() {} @@ -27,6 +38,11 @@ class Screen void setFunctionSymbol(std::string) {} void removeFunctionSymbol(std::string) {} void startAlert(const char *) {} + void showOverlayBanner(const char *message, uint32_t durationMs = 3000, uint8_t options = 0, + std::function bannerCallback = NULL, int8_t InitialSelected = 0) + { + } + void setFrames(FrameFocus focus) {} void endAlert() {} }; } // namespace graphics @@ -64,6 +80,7 @@ class Screen #include "mesh/MeshModule.h" #include "power.h" #include +#include // 0 to 255, though particular variants might define different defaults #ifndef BRIGHTNESS_DEFAULT @@ -90,7 +107,7 @@ class Screen /// Convert an integer GPS coords to a floating point #define DegD(i) (i * 1e-7) - +extern bool hasUnreadMessage; namespace { /// A basic 2D point class for drawing @@ -181,9 +198,23 @@ class Screen : public concurrency::OSThread public: explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY); - + size_t frameCount = 0; // Total number of active frames ~Screen(); + // Which frame we want to be displayed, after we regen the frameset by calling setFrames + enum FrameFocus : uint8_t { + FOCUS_DEFAULT, // No specific frame + FOCUS_PRESERVE, // Return to the previous frame + FOCUS_FAULT, + FOCUS_TEXTMESSAGE, + FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus + }; + + // Regenerate the normal set of frames, focusing a specific frame if requested + // Call when a frame should be added / removed, or custom frames should be cleared + void setFrames(FrameFocus focus = FOCUS_DEFAULT); + + std::vector indicatorIcons; // Per-frame custom icon pointers Screen(const Screen &) = delete; Screen &operator=(const Screen &) = delete; @@ -191,6 +222,12 @@ class Screen : public concurrency::OSThread meshtastic_Config_DisplayConfig_OledType model; OLEDDISPLAY_GEOMETRY geometry; + bool isOverlayBannerShowing(); + + // Stores the last 4 of our hardware ID, to make finding the device for pairing easier + // FIXME: Needs refactoring and getMacAddr needs to be moved to a utility class + char ourId[5]; + /// Initializes the UI, turns on the display, starts showing boot screen. // // Not thread safe - must be called before any other methods are called. @@ -214,21 +251,9 @@ class Screen : public concurrency::OSThread void blink(); - void drawFrameText(OLEDDisplay *, OLEDDisplayUiState *, int16_t, int16_t, const char *); - - void getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength); - // Draw north - void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading); - - static uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight); - float estimatedHeading(double lat, double lon); - void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian); - - void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields); - /// Handle button press, trackball or swipe action) void onPress() { enqueueCmd(ScreenCmd{.cmd = Cmd::ON_PRESS}); } void showPrevFrame() { enqueueCmd(ScreenCmd{.cmd = Cmd::SHOW_PREV_FRAME}); } @@ -260,6 +285,9 @@ class Screen : public concurrency::OSThread enqueueCmd(cmd); } + void showOverlayBanner(const char *message, uint32_t durationMs = 3000, uint8_t options = 0, + std::function bannerCallback = NULL, int8_t InitialSelected = 0); + void startFirmwareUpdateScreen() { ScreenCmd cmd; @@ -292,23 +320,6 @@ class Screen : public concurrency::OSThread /// Stops showing the boot screen. void stopBootScreen() { enqueueCmd(ScreenCmd{.cmd = Cmd::STOP_BOOT_SCREEN}); } - /// Writes a string to the screen. - void print(const char *text) - { - ScreenCmd cmd; - cmd.cmd = Cmd::PRINT; - // TODO(girts): strdup() here is scary, but we can't use std::string as - // FreeRTOS queue is just dumbly copying memory contents. It would be - // nice if we had a queue that could copy objects by value. - cmd.print_text = strdup(text); - if (!enqueueCmd(cmd)) { - free(cmd.print_text); - } - } - - /// generates a very brief time delta display - std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds); - /// Overrides the default utf8 character conversion, to replace empty space with question marks static char customFontTableLookup(const uint8_t ch) { @@ -541,8 +552,6 @@ class Screen : public concurrency::OSThread /// Draws our SSL cert screen during boot (called from WebServer) void setSSLFrames(); - void setWelcomeFrames(); - // Dismiss the currently focussed frame, if possible (e.g. text message, waypoint) void dismissCurrentFrame(); @@ -591,8 +600,9 @@ class Screen : public concurrency::OSThread void handleOnPress(); void handleShowNextFrame(); void handleShowPrevFrame(); - void handlePrint(const char *text); void handleStartFirmwareUpdateScreen(); + void TZPicker(); + void LoraRegionPicker(uint32_t duration = 30000); // Info collected by setFrames method. // Index location of specific frames. @@ -600,30 +610,32 @@ class Screen : public concurrency::OSThread // - Used to dismiss the currently shown frame (txt; waypoint) by CardKB combo struct FramesetInfo { struct FramePositions { - uint8_t fault = 0; - uint8_t textMessage = 0; - uint8_t waypoint = 0; - uint8_t focusedModule = 0; - uint8_t log = 0; - uint8_t settings = 0; - uint8_t wifi = 0; + uint8_t fault = 255; + uint8_t textMessage = 255; + uint8_t waypoint = 255; + uint8_t focusedModule = 255; + uint8_t log = 255; + uint8_t settings = 255; + uint8_t wifi = 255; + uint8_t deviceFocused = 255; + uint8_t memory = 255; + uint8_t gps = 255; + uint8_t home = 255; + uint8_t clock = 255; + uint8_t firstFavorite = 255; + uint8_t lastFavorite = 255; + uint8_t lora = 255; } positions; uint8_t frameCount = 0; } framesetInfo; - // Which frame we want to be displayed, after we regen the frameset by calling setFrames - enum FrameFocus : uint8_t { - FOCUS_DEFAULT, // No specific frame - FOCUS_PRESERVE, // Return to the previous frame - FOCUS_FAULT, - FOCUS_TEXTMESSAGE, - FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus - }; - - // Regenerate the normal set of frames, focusing a specific frame if requested - // Call when a frame should be added / removed, or custom frames should be cleared - void setFrames(FrameFocus focus = FOCUS_DEFAULT); + struct DismissedFrames { + bool textMessage = false; + bool waypoint = false; + bool wifi = false; + bool memory = false; + } dismissedFrames; /// Try to start drawing ASAP void setFastFramerate(); @@ -631,34 +643,6 @@ class Screen : public concurrency::OSThread // Sets frame up for immediate drawing void setFrameImmediateDraw(FrameCallback *drawFrames); - /// Called when debug screen is to be drawn, calls through to debugInfo.drawFrame. - static void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - - static void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - - static void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - -#if defined(DISPLAY_CLOCK_FRAME) - static void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - - static void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - - static void drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale = 1); - - static void drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height); - - static void drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height); - - static void drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale = 1); - - static void drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode = true, float scale = 1); - - static void drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y); - - // Whether we are showing the digital watch face or the analog one - bool digitalWatchFace = true; -#endif - /// callback for current alert frame FrameCallback alertFrame; @@ -691,4 +675,8 @@ class Screen : public concurrency::OSThread } // namespace graphics +// Extern declarations for function symbols used in UIRenderer +extern std::vector functionSymbol; +extern std::string functionSymbolString; + #endif \ No newline at end of file diff --git a/src/graphics/ScreenGlobals.cpp b/src/graphics/ScreenGlobals.cpp new file mode 100644 index 000000000..bc139faaf --- /dev/null +++ b/src/graphics/ScreenGlobals.cpp @@ -0,0 +1,6 @@ +#include +#include + +// Global variables for screen function overlay +std::vector functionSymbol; +std::string functionSymbolString; diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp new file mode 100644 index 000000000..af427cae4 --- /dev/null +++ b/src/graphics/SharedUIDisplay.cpp @@ -0,0 +1,323 @@ +#include "graphics/SharedUIDisplay.h" +#include "RTC.h" +#include "graphics/ScreenFonts.h" +#include "main.h" +#include "meshtastic/config.pb.h" +#include "power.h" +#include +#include + +namespace graphics +{ + +// === Shared External State === +bool hasUnreadMessage = false; +bool isMuted = false; + +// === Internal State === +bool isBoltVisibleShared = true; +uint32_t lastBlinkShared = 0; +bool isMailIconVisible = true; +uint32_t lastMailBlink = 0; + +// ********************************* +// * Rounded Header when inverted * +// ********************************* +void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, int16_t h, int16_t r) +{ + // Draw the center and side rectangles + display->fillRect(x + r, y, w - 2 * r, h); // center bar + display->fillRect(x, y + r, r, h - 2 * r); // left edge + display->fillRect(x + w - r, y + r, r, h - 2 * r); // right edge + + // Draw the rounded corners using filled circles + display->fillCircle(x + r + 1, y + r, r); // top-left + display->fillCircle(x + w - r - 1, y + r, r); // top-right + display->fillCircle(x + r + 1, y + h - r - 1, r); // bottom-left + display->fillCircle(x + w - r - 1, y + h - r - 1, r); // bottom-right +} + +// ************************* +// * Common Header Drawing * +// ************************* +void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr) +{ + constexpr int HEADER_OFFSET_Y = 1; + y += HEADER_OFFSET_Y; + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const int xOffset = 4; + const int highlightHeight = FONT_HEIGHT_SMALL - 1; + const bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED); + const bool isBold = config.display.heading_bold; + + const int screenW = display->getWidth(); + const int screenH = display->getHeight(); + + const bool useBigIcons = (screenW > 128); + + // === Inverted Header Background === + if (isInverted) { + drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2); + display->setColor(BLACK); + } else { + display->setColor(BLACK); + display->fillRect(0, 0, screenW, highlightHeight + 3); + display->setColor(WHITE); + if (screenW > 128) { + display->drawLine(0, 20, screenW, 20); + } else { + display->drawLine(0, 14, screenW, 14); + } + } + + // === Screen Title === + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(SCREEN_WIDTH / 2, y, titleStr); + if (config.display.heading_bold) { + display->drawString((SCREEN_WIDTH / 2) + 1, y, titleStr); + } + display->setTextAlignment(TEXT_ALIGN_LEFT); + + // === Battery State === + int chargePercent = powerStatus->getBatteryChargePercent(); + bool isCharging = powerStatus->getIsCharging() == meshtastic::OptionalBool::OptTrue; + uint32_t now = millis(); + +#ifndef USE_EINK + if (isCharging && now - lastBlinkShared > 500) { + isBoltVisibleShared = !isBoltVisibleShared; + lastBlinkShared = now; + } +#endif + + bool useHorizontalBattery = (screenW > 128 && screenW >= screenH); + const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2; + + // === Battery Icons === + if (useHorizontalBattery) { + int batteryX = 2; + int batteryY = HEADER_OFFSET_Y + 2; + display->drawXbm(batteryX, batteryY, 29, 15, batteryBitmap_h); + if (isCharging && isBoltVisibleShared) + display->drawXbm(batteryX + 9, batteryY + 1, 9, 13, lightning_bolt_h); + else { + display->drawXbm(batteryX + 8, batteryY, 12, 15, batteryBitmap_sidegaps_h); + int fillWidth = 24 * chargePercent / 100; + display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 13); + } + } else { + int batteryX = 1; + int batteryY = HEADER_OFFSET_Y + 1; +#ifdef USE_EINK + batteryY += 2; +#endif + display->drawXbm(batteryX, batteryY, 7, 11, batteryBitmap_v); + if (isCharging && isBoltVisibleShared) + display->drawXbm(batteryX + 1, batteryY + 3, 5, 5, lightning_bolt_v); + else { + display->drawXbm(batteryX - 1, batteryY + 4, 8, 3, batteryBitmap_sidegaps_v); + int fillHeight = 8 * chargePercent / 100; + int fillY = batteryY - fillHeight; + display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight); + } + } + + // === Battery % Display === + char chargeStr[4]; + snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent); + int chargeNumWidth = display->getStringWidth(chargeStr); + const int batteryOffset = useHorizontalBattery ? 28 : 6; +#ifdef USE_EINK + const int percentX = x + xOffset + batteryOffset - 2; +#else + const int percentX = x + xOffset + batteryOffset; +#endif + display->drawString(percentX, textY, chargeStr); + display->drawString(percentX + chargeNumWidth - 1, textY, "%"); + if (isBold) { + display->drawString(percentX + 1, textY, chargeStr); + display->drawString(percentX + chargeNumWidth, textY, "%"); + } + + // === Time and Right-aligned Icons === + uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); + char timeStr[10] = "--:--"; // Fallback display + int timeStrWidth = display->getStringWidth("12:34"); // Default alignment + int timeX = screenW - xOffset - timeStrWidth + 4; + + if (rtc_sec > 0) { + // === Build Time String === + long hms = (rtc_sec % SEC_PER_DAY + SEC_PER_DAY) % SEC_PER_DAY; + int hour = hms / SEC_PER_HOUR; + int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; + snprintf(timeStr, sizeof(timeStr), "%d:%02d", hour, minute); + + if (config.display.use_12h_clock) { + bool isPM = hour >= 12; + hour %= 12; + if (hour == 0) + hour = 12; + snprintf(timeStr, sizeof(timeStr), "%d:%02d%s", hour, minute, isPM ? "p" : "a"); + } + + timeStrWidth = display->getStringWidth(timeStr); + timeX = screenW - xOffset - timeStrWidth + 4; + + // === Show Mail or Mute Icon to the Left of Time === + int iconRightEdge = timeX - 1; + + bool showMail = false; + +#ifndef USE_EINK + if (hasUnreadMessage) { + if (now - lastMailBlink > 500) { + isMailIconVisible = !isMailIconVisible; + lastMailBlink = now; + } + showMail = isMailIconVisible; + } +#else + if (hasUnreadMessage) { + showMail = true; + } +#endif + + if (showMail) { + if (useHorizontalBattery) { + int iconW = 16, iconH = 12; + int iconX = iconRightEdge - iconW; + int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1; + if (isInverted) { + display->setColor(WHITE); + display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2); + display->setColor(BLACK); + } else { + display->setColor(BLACK); + display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2); + display->setColor(WHITE); + } + display->drawRect(iconX, iconY, iconW + 1, iconH); + display->drawLine(iconX, iconY, iconX + iconW / 2, iconY + iconH - 4); + display->drawLine(iconX + iconW, iconY, iconX + iconW / 2, iconY + iconH - 4); + } else { + int iconX = iconRightEdge - (mail_width - 2); + int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; + if (isInverted) { + display->setColor(WHITE); + display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2); + display->setColor(BLACK); + } else { + display->setColor(BLACK); + display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2); + display->setColor(WHITE); + } + display->drawXbm(iconX, iconY, mail_width, mail_height, mail); + } + } else if (isMuted) { + if (useBigIcons) { + int iconX = iconRightEdge - mute_symbol_big_width; + int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2; + + if (isInverted) { + display->setColor(WHITE); + display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2); + display->setColor(BLACK); + } else { + display->setColor(BLACK); + display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2); + display->setColor(WHITE); + } + display->drawXbm(iconX, iconY, mute_symbol_big_width, mute_symbol_big_height, mute_symbol_big); + } else { + int iconX = iconRightEdge - mute_symbol_width; + int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; + + if (isInverted) { + display->setColor(WHITE); + display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2); + display->setColor(BLACK); + } else { + display->setColor(BLACK); + display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2); + display->setColor(WHITE); + } + display->drawXbm(iconX, iconY, mute_symbol_width, mute_symbol_height, mute_symbol); + } + } + + // === Draw Time === + display->drawString(timeX, textY, timeStr); + if (isBold) + display->drawString(timeX - 1, textY, timeStr); + + } else { + // === No Time Available: Mail/Mute Icon Moves to Far Right === + int iconRightEdge = screenW - xOffset; + + bool showMail = false; + + if (hasUnreadMessage) { + if (now - lastMailBlink > 500) { + isMailIconVisible = !isMailIconVisible; + lastMailBlink = now; + } + showMail = isMailIconVisible; + } + + if (showMail) { + if (useHorizontalBattery) { + int iconW = 16, iconH = 12; + int iconX = iconRightEdge - iconW; + int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1; + display->drawRect(iconX, iconY, iconW + 1, iconH); + display->drawLine(iconX, iconY, iconX + iconW / 2, iconY + iconH - 4); + display->drawLine(iconX + iconW, iconY, iconX + iconW / 2, iconY + iconH - 4); + } else { + int iconX = iconRightEdge - mail_width; + int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; + display->drawXbm(iconX, iconY, mail_width, mail_height, mail); + } + } else if (isMuted) { + if (useBigIcons) { + int iconX = iconRightEdge - mute_symbol_big_width; + int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2; + display->drawXbm(iconX, iconY, mute_symbol_big_width, mute_symbol_big_height, mute_symbol_big); + } else { + int iconX = iconRightEdge - mute_symbol_width; + int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; + display->drawXbm(iconX, iconY, mute_symbol_width, mute_symbol_height, mute_symbol); + } + } + } + + display->setColor(WHITE); // Reset for other UI +} + +const int *getTextPositions(OLEDDisplay *display) +{ + static int textPositions[7]; // Static array that persists beyond function scope + + if (display->getHeight() > 64) { + textPositions[0] = textZeroLine; + textPositions[1] = textFirstLine_medium; + textPositions[2] = textSecondLine_medium; + textPositions[3] = textThirdLine_medium; + textPositions[4] = textFourthLine_medium; + textPositions[5] = textFifthLine_medium; + textPositions[6] = textSixthLine_medium; + } else { + textPositions[0] = textZeroLine; + textPositions[1] = textFirstLine; + textPositions[2] = textSecondLine; + textPositions[3] = textThirdLine; + textPositions[4] = textFourthLine; + textPositions[5] = textFifthLine; + textPositions[6] = textSixthLine; + } + return textPositions; +} + +} // namespace graphics diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h new file mode 100644 index 000000000..41411ba7f --- /dev/null +++ b/src/graphics/SharedUIDisplay.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +namespace graphics +{ + +// ======================= +// Shared UI Helpers +// ======================= + +#define textZeroLine 0 +// Consistent Line Spacing - this is standard for all display and the fall-back spacing +#define textFirstLine (FONT_HEIGHT_SMALL - 1) +#define textSecondLine (textFirstLine + (FONT_HEIGHT_SMALL - 5)) +#define textThirdLine (textSecondLine + (FONT_HEIGHT_SMALL - 5)) +#define textFourthLine (textThirdLine + (FONT_HEIGHT_SMALL - 5)) +#define textFifthLine (textFourthLine + (FONT_HEIGHT_SMALL - 5)) +#define textSixthLine (textFifthLine + (FONT_HEIGHT_SMALL - 5)) + +// Consistent Line Spacing for devices like T114 and TEcho/ThinkNode M1 of devices +#define textFirstLine_medium (FONT_HEIGHT_SMALL + 1) +#define textSecondLine_medium (textFirstLine_medium + FONT_HEIGHT_SMALL) +#define textThirdLine_medium (textSecondLine_medium + FONT_HEIGHT_SMALL) +#define textFourthLine_medium (textThirdLine_medium + FONT_HEIGHT_SMALL) +#define textFifthLine_medium (textFourthLine_medium + FONT_HEIGHT_SMALL) +#define textSixthLine_medium (textFifthLine_medium + FONT_HEIGHT_SMALL) + +// Consistent Line Spacing for devices like VisionMaster T190 +#define textFirstLine_large (FONT_HEIGHT_SMALL + 1) +#define textSecondLine_large (textFirstLine_large + (FONT_HEIGHT_SMALL + 5)) +#define textThirdLine_large (textSecondLine_large + (FONT_HEIGHT_SMALL + 5)) +#define textFourthLine_large (textThirdLine_large + (FONT_HEIGHT_SMALL + 5)) +#define textFifthLine_large (textFourthLine_large + (FONT_HEIGHT_SMALL + 5)) +#define textSixthLine_large (textFifthLine_large + (FONT_HEIGHT_SMALL + 5)) + +// Quick screen access +#define SCREEN_WIDTH display->getWidth() +#define SCREEN_HEIGHT display->getHeight() + +// Shared state (declare inside namespace) +extern bool hasUnreadMessage; +extern bool isMuted; + +// Rounded highlight (used for inverted headers) +void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, int16_t h, int16_t r); + +// Shared battery/time/mail header +void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = ""); + +const int *getTextPositions(OLEDDisplay *display); + +} // namespace graphics diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 76fe6b2d3..92b2c3d02 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -662,7 +662,7 @@ static LGFX *tft = nullptr; #include // Graphics and font library for ILI9342 driver chip static TFT_eSPI *tft = nullptr; // Invoke library, pins defined in User_Setup.h -#elif ARCH_PORTDUINO && HAS_SCREEN != 0 && !HAS_TFT +#elif ARCH_PORTDUINO #include // Graphics and font library for ST7735 driver chip class LGFX : public lgfx::LGFX_Device @@ -706,11 +706,16 @@ class LGFX : public lgfx::LGFX_Device _panel_instance->setBus(&_bus_instance); // set the bus on the panel. auto cfg = _panel_instance->config(); // Gets a structure for display panel settings. - LOG_DEBUG("Height: %d, Width: %d ", settingsMap[displayHeight], settingsMap[displayWidth]); + LOG_DEBUG("Width: %d, Height: %d", settingsMap[displayWidth], settingsMap[displayHeight]); cfg.pin_cs = settingsMap[displayCS]; // Pin number where CS is connected (-1 = disable) cfg.pin_rst = settingsMap[displayReset]; - cfg.panel_width = settingsMap[displayWidth]; // actual displayable width - cfg.panel_height = settingsMap[displayHeight]; // actual displayable height + if (settingsMap[displayRotate]) { + cfg.panel_width = settingsMap[displayHeight]; // actual displayable width + cfg.panel_height = settingsMap[displayWidth]; // actual displayable height + } else { + cfg.panel_width = settingsMap[displayWidth]; // actual displayable width + cfg.panel_height = settingsMap[displayHeight]; // actual displayable height + } cfg.offset_x = settingsMap[displayOffsetX]; // Panel offset amount in X direction cfg.offset_y = settingsMap[displayOffsetY]; // Panel offset amount in Y direction cfg.offset_rotation = settingsMap[displayOffsetRotate]; // Rotation direction value offset 0~7 (4~7 is mirrored) @@ -987,9 +992,9 @@ TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY g #if ARCH_PORTDUINO if (settingsMap[displayRotate]) { - setGeometry(GEOMETRY_RAWMODE, settingsMap[configNames::displayHeight], settingsMap[configNames::displayWidth]); - } else { setGeometry(GEOMETRY_RAWMODE, settingsMap[configNames::displayWidth], settingsMap[configNames::displayHeight]); + } else { + setGeometry(GEOMETRY_RAWMODE, settingsMap[configNames::displayHeight], settingsMap[configNames::displayWidth]); } #elif defined(SCREEN_ROTATE) @@ -1178,6 +1183,8 @@ bool TFTDisplay::connect() tft->setRotation(1); // T-Deck has the TFT in landscape #elif defined(T_WATCH_S3) || defined(SENSECAP_INDICATOR) tft->setRotation(2); // T-Watch S3 left-handed orientation +#elif ARCH_PORTDUINO + tft->setRotation(0); // use config.yaml to set rotation #else tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label #endif diff --git a/src/graphics/TimeFormatters.cpp b/src/graphics/TimeFormatters.cpp new file mode 100644 index 000000000..47036078b --- /dev/null +++ b/src/graphics/TimeFormatters.cpp @@ -0,0 +1,103 @@ +#include "TimeFormatters.h" +#include "configuration.h" +#include "gps/RTC.h" +#include "mesh/NodeDB.h" +#include + +bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int32_t *daysAgo) +{ + // Cache the result - avoid frequent recalculation + static uint8_t hoursCached = 0, minutesCached = 0; + static uint32_t daysAgoCached = 0; + static uint32_t secondsAgoCached = 0; + static bool validCached = false; + + // Abort: if timezone not set + if (strlen(config.device.tzdef) == 0) { + validCached = false; + return validCached; + } + + // Abort: if invalid pointers passed + if (hours == nullptr || minutes == nullptr || daysAgo == nullptr) { + validCached = false; + return validCached; + } + + // Abort: if time seems invalid.. (> 6 months ago, probably seen before RTC set) + if (secondsAgo > SEC_PER_DAY * 30UL * 6) { + validCached = false; + return validCached; + } + + // If repeated request, don't bother recalculating + if (secondsAgo - secondsAgoCached < 60 && secondsAgoCached != 0) { + if (validCached) { + *hours = hoursCached; + *minutes = minutesCached; + *daysAgo = daysAgoCached; + } + return validCached; + } + + // Get local time + uint32_t secondsRTC = getValidTime(RTCQuality::RTCQualityDevice, true); // Get local time + + // Abort: if RTC not set + if (!secondsRTC) { + validCached = false; + return validCached; + } + + // Get absolute time when last seen + uint32_t secondsSeenAt = secondsRTC - secondsAgo; + + // Calculate daysAgo + *daysAgo = (secondsRTC / SEC_PER_DAY) - (secondsSeenAt / SEC_PER_DAY); // How many "midnights" have passed + + // Get seconds since midnight + uint32_t hms = (secondsRTC - secondsAgo) % SEC_PER_DAY; + hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; + + // Tear apart hms into hours and minutes + *hours = hms / SEC_PER_HOUR; + *minutes = (hms % SEC_PER_HOUR) / SEC_PER_MIN; + + // Cache the result + daysAgoCached = *daysAgo; + hoursCached = *hours; + minutesCached = *minutes; + secondsAgoCached = secondsAgo; + + validCached = true; + return validCached; +} + +void getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength) +{ + // Use an absolute timestamp in some cases. + // Particularly useful with E-Ink displays. Static UI, fewer refreshes. + uint8_t timestampHours, timestampMinutes; + int32_t daysAgo; + bool useTimestamp = deltaToTimestamp(agoSecs, ×tampHours, ×tampMinutes, &daysAgo); + + if (agoSecs < 120) // last 2 mins? + snprintf(timeStr, maxLength, "%u seconds ago", agoSecs); + // -- if suitable for timestamp -- + else if (useTimestamp && agoSecs < 15 * SECONDS_IN_MINUTE) // Last 15 minutes + snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / SECONDS_IN_MINUTE); + else if (useTimestamp && daysAgo == 0) // Today + snprintf(timeStr, maxLength, "Last seen: %02u:%02u", (unsigned int)timestampHours, (unsigned int)timestampMinutes); + else if (useTimestamp && daysAgo == 1) // Yesterday + snprintf(timeStr, maxLength, "Seen yesterday"); + else if (useTimestamp && daysAgo > 1) // Last six months (capped by deltaToTimestamp method) + snprintf(timeStr, maxLength, "%li days ago", (long)daysAgo); + // -- if using time delta instead -- + else if (agoSecs < 120 * 60) // last 2 hrs + snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / 60); + // Only show hours ago if it's been less than 6 months. Otherwise, we may have bad data. + else if ((agoSecs / 60 / 60) < (730 * 6)) + snprintf(timeStr, maxLength, "%u hours ago", agoSecs / 60 / 60); + else + snprintf(timeStr, maxLength, "unknown age"); +} diff --git a/src/graphics/TimeFormatters.h b/src/graphics/TimeFormatters.h new file mode 100644 index 000000000..b3d8413a2 --- /dev/null +++ b/src/graphics/TimeFormatters.h @@ -0,0 +1,26 @@ +#pragma once + +#include "configuration.h" +#include "gps/RTC.h" +#include +#include + +/** + * Convert a delta in seconds ago to timestamp information (hours, minutes, days ago). + * + * @param secondsAgo Number of seconds ago to convert + * @param hours Pointer to store the hours (0-23) + * @param minutes Pointer to store the minutes (0-59) + * @param daysAgo Pointer to store the number of days ago + * @return true if conversion was successful, false if invalid input or time not available + */ +bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int32_t *daysAgo); + +/** + * Get a human-readable string representing the time ago in a format like "2 days, 3 hours, 15 minutes". + * + * @param agoSecs Number of seconds ago to convert + * @param timeStr Pointer to store the resulting string + * @param maxLength Maximum length of the resulting string buffer + */ +void getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength); diff --git a/src/graphics/draw/ClockRenderer.cpp b/src/graphics/draw/ClockRenderer.cpp new file mode 100644 index 000000000..2e301b4e1 --- /dev/null +++ b/src/graphics/draw/ClockRenderer.cpp @@ -0,0 +1,473 @@ +#include "configuration.h" +#if HAS_SCREEN +#include "ClockRenderer.h" +#include "NodeDB.h" +#include "UIRenderer.h" +#include "configuration.h" +#include "gps/GeoCoord.h" +#include "gps/RTC.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/emotes.h" +#include "graphics/images.h" +#include "main.h" + +#if !MESHTASTIC_EXCLUDE_BLUETOOTH +#include "nimble/NimbleBluetooth.h" +#endif + +namespace graphics +{ + +namespace ClockRenderer +{ + +void drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale) +{ + uint16_t segmentWidth = SEGMENT_WIDTH * scale; + uint16_t segmentHeight = SEGMENT_HEIGHT * scale; + + uint16_t cellHeight = (segmentWidth * 2) + (segmentHeight * 3) + 8; + + uint16_t topAndBottomX = x + (4 * scale); + + uint16_t quarterCellHeight = cellHeight / 4; + + uint16_t topY = y + quarterCellHeight; + uint16_t bottomY = y + (quarterCellHeight * 3); + + display->fillRect(topAndBottomX, topY, segmentHeight, segmentHeight); + display->fillRect(topAndBottomX, bottomY, segmentHeight, segmentHeight); +} + +void drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale) +{ + // the numbers 0-9, each expressed as an array of seven boolean (0|1) values encoding the on/off state of + // segment {innerIndex + 1} + // e.g., to display the numeral '0', segments 1-6 are on, and segment 7 is off. + uint8_t numbers[10][7] = { + {1, 1, 1, 1, 1, 1, 0}, // 0 Display segment key + {0, 1, 1, 0, 0, 0, 0}, // 1 1 + {1, 1, 0, 1, 1, 0, 1}, // 2 ___ + {1, 1, 1, 1, 0, 0, 1}, // 3 6 | | 2 + {0, 1, 1, 0, 0, 1, 1}, // 4 |_7̲_| + {1, 0, 1, 1, 0, 1, 1}, // 5 5 | | 3 + {1, 0, 1, 1, 1, 1, 1}, // 6 |___| + {1, 1, 1, 0, 0, 1, 0}, // 7 + {1, 1, 1, 1, 1, 1, 1}, // 8 4 + {1, 1, 1, 1, 0, 1, 1}, // 9 + }; + + // the width and height of each segment's central rectangle: + // _____________________ + // ⋰| (only this part, |⋱ + // ⋰ | not including | ⋱ + // ⋱ | the triangles | ⋰ + // ⋱| on the ends) |⋰ + // ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + + uint16_t segmentWidth = SEGMENT_WIDTH * scale; + uint16_t segmentHeight = SEGMENT_HEIGHT * scale; + + // segment x and y coordinates + uint16_t segmentOneX = x + segmentHeight + 2; + uint16_t segmentOneY = y; + + uint16_t segmentTwoX = segmentOneX + segmentWidth + 2; + uint16_t segmentTwoY = segmentOneY + segmentHeight + 2; + + uint16_t segmentThreeX = segmentTwoX; + uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2 + segmentHeight + 2; + + uint16_t segmentFourX = segmentOneX; + uint16_t segmentFourY = segmentThreeY + segmentWidth + 2; + + uint16_t segmentFiveX = x; + uint16_t segmentFiveY = segmentThreeY; + + uint16_t segmentSixX = x; + uint16_t segmentSixY = segmentTwoY; + + uint16_t segmentSevenX = segmentOneX; + uint16_t segmentSevenY = segmentTwoY + segmentWidth + 2; + + if (numbers[number][0]) { + graphics::ClockRenderer::drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight); + } + + if (numbers[number][1]) { + graphics::ClockRenderer::drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight); + } + + if (numbers[number][2]) { + graphics::ClockRenderer::drawVerticalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight); + } + + if (numbers[number][3]) { + graphics::ClockRenderer::drawHorizontalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight); + } + + if (numbers[number][4]) { + graphics::ClockRenderer::drawVerticalSegment(display, segmentFiveX, segmentFiveY, segmentWidth, segmentHeight); + } + + if (numbers[number][5]) { + graphics::ClockRenderer::drawVerticalSegment(display, segmentSixX, segmentSixY, segmentWidth, segmentHeight); + } + + if (numbers[number][6]) { + graphics::ClockRenderer::drawHorizontalSegment(display, segmentSevenX, segmentSevenY, segmentWidth, segmentHeight); + } +} + +void drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height) +{ + int halfHeight = height / 2; + + // draw central rectangle + display->fillRect(x, y, width, height); + + // draw end triangles + display->fillTriangle(x, y, x, y + height - 1, x - halfHeight, y + halfHeight); + + display->fillTriangle(x + width, y, x + width + halfHeight, y + halfHeight, x + width, y + height - 1); +} + +void drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height) +{ + int halfHeight = height / 2; + + // draw central rectangle + display->fillRect(x, y, height, width); + + // draw end triangles + display->fillTriangle(x + halfHeight, y - halfHeight, x + height - 1, y, x, y); + + display->fillTriangle(x, y + width, x + height - 1, y + width, x + halfHeight, y + width + halfHeight); +} + +void drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode, float scale) +{ + uint16_t segmentWidth = SEGMENT_WIDTH * scale; + uint16_t segmentHeight = SEGMENT_HEIGHT * scale; + + if (digitalMode) { + uint16_t radius = (segmentWidth + (segmentHeight * 2) + 4) / 2; + uint16_t centerX = (x + segmentHeight + 2) + (radius / 2); + uint16_t centerY = (y + segmentHeight + 2) + (radius / 2); + + display->drawCircle(centerX, centerY, radius); + display->drawCircle(centerX, centerY, radius + 1); + display->drawLine(centerX, centerY, centerX, centerY - radius + 3); + display->drawLine(centerX, centerY, centerX + radius - 3, centerY); + } else { + uint16_t segmentOneX = x + segmentHeight + 2; + uint16_t segmentOneY = y; + + uint16_t segmentTwoX = segmentOneX + segmentWidth + 2; + uint16_t segmentTwoY = segmentOneY + segmentHeight + 2; + + uint16_t segmentThreeX = segmentOneX; + uint16_t segmentThreeY = segmentTwoY + segmentWidth + 2; + + uint16_t segmentFourX = x; + uint16_t segmentFourY = y + segmentHeight + 2; + + drawHorizontalSegment(display, segmentOneX, segmentOneY, segmentWidth, segmentHeight); + drawVerticalSegment(display, segmentTwoX, segmentTwoY, segmentWidth, segmentHeight); + drawHorizontalSegment(display, segmentThreeX, segmentThreeY, segmentWidth, segmentHeight); + drawVerticalSegment(display, segmentFourX, segmentFourY, segmentWidth, segmentHeight); + } +} + +// Draw a digital clock +void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + int line = 1; + +#ifdef T_WATCH_S3 + if (nimbleBluetooth && nimbleBluetooth->isConnected()) { + graphics::ClockRenderer::drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2); + } + + drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36, + graphics::ClockRenderer::digitalWatchFace, 1); +#endif + + uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone + char timeString[16]; + int hour = 0; + int minute = 0; + int second = 0; + if (rtc_sec > 0) { + long hms = rtc_sec % SEC_PER_DAY; + hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; + + hour = hms / SEC_PER_HOUR; + minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; + second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN + } + + bool isPM = hour >= 12; + // hour = hour > 12 ? hour - 12 : hour; + if (config.display.use_12h_clock) { + hour %= 12; + if (hour == 0) + hour = 12; + bool isPM = hour >= 12; + snprintf(timeString, sizeof(timeString), "%d:%02d", hour, minute); + } else { + snprintf(timeString, sizeof(timeString), "%02d:%02d", hour, minute); + } + + // Format seconds string + char secondString[8]; + snprintf(secondString, sizeof(secondString), "%02d", second); + +#ifdef T_WATCH_S3 + float scale = 1.5; +#else + float scale = 0.75; + if (SCREEN_WIDTH > 128) { + scale = 1.5; + } +#endif + + uint16_t segmentWidth = SEGMENT_WIDTH * scale; + uint16_t segmentHeight = SEGMENT_HEIGHT * scale; + + // calculate hours:minutes string width + uint16_t timeStringWidth = strlen(timeString) * 5; + + for (uint8_t i = 0; i < strlen(timeString); i++) { + char character = timeString[i]; + + if (character == ':') { + timeStringWidth += segmentHeight; + } else { + timeStringWidth += segmentWidth + (segmentHeight * 2) + 4; + } + } + + uint16_t hourMinuteTextX = (display->getWidth() / 2) - (timeStringWidth / 2); + + uint16_t startingHourMinuteTextX = hourMinuteTextX; + + uint16_t hourMinuteTextY = (display->getHeight() / 2) - (((segmentWidth * 2) + (segmentHeight * 3) + 8) / 2); + + // iterate over characters in hours:minutes string and draw segmented characters + for (uint8_t i = 0; i < strlen(timeString); i++) { + char character = timeString[i]; + + if (character == ':') { + drawSegmentedDisplayColon(display, hourMinuteTextX, hourMinuteTextY, scale); + + hourMinuteTextX += segmentHeight + 6; + } else { + drawSegmentedDisplayCharacter(display, hourMinuteTextX, hourMinuteTextY, character - '0', scale); + + hourMinuteTextX += segmentWidth + (segmentHeight * 2) + 4; + } + + hourMinuteTextX += 5; + } + + // draw seconds string + display->setFont(FONT_SMALL); + int xOffset = (SCREEN_WIDTH > 128) ? 0 : -1; + if (hour >= 10) { + xOffset += (SCREEN_WIDTH > 128) ? 32 : 18; + } + int yOffset = (SCREEN_WIDTH > 128) ? 3 : 1; + if (config.display.use_12h_clock) { + display->drawString(startingHourMinuteTextX + xOffset, (display->getHeight() - hourMinuteTextY) - yOffset - 2, + isPM ? "pm" : "am"); + } +#ifndef USE_EINK + xOffset = (SCREEN_WIDTH > 128) ? 18 : 10; + display->drawString(startingHourMinuteTextX + timeStringWidth - xOffset, (display->getHeight() - hourMinuteTextY) - yOffset, + secondString); +#endif +} + +void drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->drawFastImage(x, y, 18, 14, bluetoothConnectedIcon); +} + +// Draw an analog clock +void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setTextAlignment(TEXT_ALIGN_LEFT); + + graphics::UIRenderer::drawBattery(display, x, y + 7, imgBattery, powerStatus); + + if (powerStatus->getHasBattery()) { + char batteryPercent[8]; + snprintf(batteryPercent, sizeof(batteryPercent), "%d%%", powerStatus->getBatteryChargePercent()); + + display->setFont(FONT_SMALL); + + display->drawString(x + 20, y + 2, batteryPercent); + } +#ifdef T_WATCH_S3 + if (nimbleBluetooth && nimbleBluetooth->isConnected()) { + drawBluetoothConnectedIcon(display, display->getWidth() - 18, y + 2); + } +#endif + drawWatchFaceToggleButton(display, display->getWidth() - 36, display->getHeight() - 36, + graphics::ClockRenderer::digitalWatchFace, 1); + + // clock face center coordinates + int16_t centerX = display->getWidth() / 2; + int16_t centerY = display->getHeight() / 2; + + // clock face radius + int16_t radius = (display->getWidth() / 2) * 0.8; + + // noon (0 deg) coordinates (outermost circle) + int16_t noonX = centerX; + int16_t noonY = centerY - radius; + + // second hand radius and y coordinate (outermost circle) + int16_t secondHandNoonY = noonY + 1; + + // tick mark outer y coordinate; (first nested circle) + int16_t tickMarkOuterNoonY = secondHandNoonY; + + // seconds tick mark inner y coordinate; (second nested circle) + double secondsTickMarkInnerNoonY = (double)noonY + 8; + + // hours tick mark inner y coordinate; (third nested circle) + double hoursTickMarkInnerNoonY = (double)noonY + 16; + + // minute hand y coordinate + int16_t minuteHandNoonY = secondsTickMarkInnerNoonY + 4; + + // hour string y coordinate + int16_t hourStringNoonY = minuteHandNoonY + 18; + + // hour hand radius and y coordinate + int16_t hourHandRadius = radius * 0.55; + int16_t hourHandNoonY = centerY - hourHandRadius; + + display->setColor(OLEDDISPLAY_COLOR::WHITE); + display->drawCircle(centerX, centerY, radius); + + uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone + if (rtc_sec > 0) { + long hms = rtc_sec % SEC_PER_DAY; + hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; + + // Tear apart hms into h:m:s + int hour = hms / SEC_PER_HOUR; + int minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; + int second = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN + + hour = hour > 12 ? hour - 12 : hour; + + int16_t degreesPerHour = 30; + int16_t degreesPerMinuteOrSecond = 6; + + double hourBaseAngle = hour * degreesPerHour; + double hourAngleOffset = ((double)minute / 60) * degreesPerHour; + double hourAngle = radians(hourBaseAngle + hourAngleOffset); + + double minuteBaseAngle = minute * degreesPerMinuteOrSecond; + double minuteAngleOffset = ((double)second / 60) * degreesPerMinuteOrSecond; + double minuteAngle = radians(minuteBaseAngle + minuteAngleOffset); + + double secondAngle = radians(second * degreesPerMinuteOrSecond); + + double hourX = sin(-hourAngle) * (hourHandNoonY - centerY) + noonX; + double hourY = cos(-hourAngle) * (hourHandNoonY - centerY) + centerY; + + double minuteX = sin(-minuteAngle) * (minuteHandNoonY - centerY) + noonX; + double minuteY = cos(-minuteAngle) * (minuteHandNoonY - centerY) + centerY; + + double secondX = sin(-secondAngle) * (secondHandNoonY - centerY) + noonX; + double secondY = cos(-secondAngle) * (secondHandNoonY - centerY) + centerY; + + display->setFont(FONT_MEDIUM); + + // draw minute and hour tick marks and hour numbers + for (uint16_t angle = 0; angle < 360; angle += 6) { + double angleInRadians = radians(angle); + + double sineAngleInRadians = sin(-angleInRadians); + double cosineAngleInRadians = cos(-angleInRadians); + + double endX = sineAngleInRadians * (tickMarkOuterNoonY - centerY) + noonX; + double endY = cosineAngleInRadians * (tickMarkOuterNoonY - centerY) + centerY; + + if (angle % degreesPerHour == 0) { + double startX = sineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + noonX; + double startY = cosineAngleInRadians * (hoursTickMarkInnerNoonY - centerY) + centerY; + + // draw hour tick mark + display->drawLine(startX, startY, endX, endY); + + static char buffer[2]; + + uint8_t hourInt = (angle / 30); + + if (hourInt == 0) { + hourInt = 12; + } + + // hour number x offset needs to be adjusted for some cases + int8_t hourStringXOffset; + int8_t hourStringYOffset = 13; + + switch (hourInt) { + case 3: + hourStringXOffset = 5; + break; + case 9: + hourStringXOffset = 7; + break; + case 10: + case 11: + hourStringXOffset = 8; + break; + case 12: + hourStringXOffset = 13; + break; + default: + hourStringXOffset = 6; + break; + } + + double hourStringX = (sineAngleInRadians * (hourStringNoonY - centerY) + noonX) - hourStringXOffset; + double hourStringY = (cosineAngleInRadians * (hourStringNoonY - centerY) + centerY) - hourStringYOffset; + + // draw hour number + display->drawStringf(hourStringX, hourStringY, buffer, "%d", hourInt); + } + + if (angle % degreesPerMinuteOrSecond == 0) { + double startX = sineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + noonX; + double startY = cosineAngleInRadians * (secondsTickMarkInnerNoonY - centerY) + centerY; + + // draw minute tick mark + display->drawLine(startX, startY, endX, endY); + } + } + + // draw hour hand + display->drawLine(centerX, centerY, hourX, hourY); + + // draw minute hand + display->drawLine(centerX, centerY, minuteX, minuteY); + + // draw second hand + display->drawLine(centerX, centerY, secondX, secondY); + } +} + +} // namespace ClockRenderer + +} // namespace graphics +#endif \ No newline at end of file diff --git a/src/graphics/draw/ClockRenderer.h b/src/graphics/draw/ClockRenderer.h new file mode 100644 index 000000000..4660dcc35 --- /dev/null +++ b/src/graphics/draw/ClockRenderer.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +namespace graphics +{ + +/// Forward declarations +class Screen; + +namespace ClockRenderer +{ +// Whether we are showing the digital watch face or the analog one +static bool digitalWatchFace = true; + +// Clock frame functions +void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// Segmented display functions +void drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale = 1); +void drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale = 1); +void drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height); +void drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height); + +// UI elements for clock displays +void drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode = true, float scale = 1); +void drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y); + +} // namespace ClockRenderer + +} // namespace graphics diff --git a/src/graphics/draw/CompassRenderer.cpp b/src/graphics/draw/CompassRenderer.cpp new file mode 100644 index 000000000..fef993e2d --- /dev/null +++ b/src/graphics/draw/CompassRenderer.cpp @@ -0,0 +1,140 @@ +#include "CompassRenderer.h" +#include "NodeDB.h" +#include "UIRenderer.h" +#include "configuration.h" +#include "gps/GeoCoord.h" +#include "graphics/ScreenFonts.h" +#include + +namespace graphics +{ +namespace CompassRenderer +{ + +// Point helper class for compass calculations +struct Point { + float x, y; + Point(float x, float y) : x(x), y(y) {} + + void rotate(float angle) + { + float cos_a = cos(angle); + float sin_a = sin(angle); + float new_x = x * cos_a - y * sin_a; + float new_y = x * sin_a + y * cos_a; + x = new_x; + y = new_y; + } + + void scale(float factor) + { + x *= factor; + y *= factor; + } + + void translate(float dx, float dy) + { + x += dx; + y += dy; + } +}; + +void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius) +{ + // Show the compass heading (not implemented in original) + // This could draw a "N" indicator or north arrow + // For now, we'll draw a simple north indicator + // const float radius = 17.0f; + if (display->width() > 128) { + radius += 4; + } + Point north(0, -radius); + north.rotate(-myHeading); + north.translate(compassX, compassY); + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setColor(BLACK); + if (display->width() > 128) { + display->fillRect(north.x - 8, north.y - 1, display->getStringWidth("N") + 3, FONT_HEIGHT_SMALL - 6); + } else { + display->fillRect(north.x - 4, north.y - 1, display->getStringWidth("N") + 2, FONT_HEIGHT_SMALL - 6); + } + display->setColor(WHITE); + display->drawString(north.x, north.y - 3, "N"); +} + +void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian) +{ + Point tip(0.0f, -0.5f), tail(0.0f, 0.35f); // pointing up initially + float arrowOffsetX = 0.14f, arrowOffsetY = 0.9f; + Point leftArrow(tip.x - arrowOffsetX, tip.y + arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y + arrowOffsetY); + + Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow}; + + for (int i = 0; i < 4; i++) { + arrowPoints[i]->rotate(headingRadian); + arrowPoints[i]->scale(compassDiam * 0.6); + arrowPoints[i]->translate(compassX, compassY); + } + +#ifdef USE_EINK + display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); +#else + display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); +#endif + display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y); +} + +void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing) +{ + float radians = bearing * DEG_TO_RAD; + + Point tip(0, -size / 2); + Point left(-size / 4, size / 4); + Point right(size / 4, size / 4); + + tip.rotate(radians); + left.rotate(radians); + right.rotate(radians); + + tip.translate(x, y); + left.translate(x, y); + right.translate(x, y); + + display->drawTriangle(tip.x, tip.y, left.x, left.y, right.x, right.y); +} + +float estimatedHeading(double lat, double lon) +{ + // Simple magnetic declination estimation + // This is a very basic implementation - the original might be more sophisticated + return 0.0f; // Return 0 for now, indicating no heading available +} + +uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight) +{ + // Calculate appropriate compass diameter based on display size + uint16_t minDimension = (displayWidth < displayHeight) ? displayWidth : displayHeight; + uint16_t maxDiam = minDimension / 3; // Use 1/3 of the smaller dimension + + // Ensure minimum and maximum bounds + if (maxDiam < 16) + maxDiam = 16; + if (maxDiam > 64) + maxDiam = 64; + + return maxDiam; +} + +float calculateBearing(double lat1, double lon1, double lat2, double lon2) +{ + double dLon = (lon2 - lon1) * DEG_TO_RAD; + double y = sin(dLon) * cos(lat2 * DEG_TO_RAD); + double x = cos(lat1 * DEG_TO_RAD) * sin(lat2 * DEG_TO_RAD) - sin(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * cos(dLon); + double bearing = atan2(y, x) * RAD_TO_DEG; + return fmod(bearing + 360.0, 360.0); +} + +} // namespace CompassRenderer +} // namespace graphics diff --git a/src/graphics/draw/CompassRenderer.h b/src/graphics/draw/CompassRenderer.h new file mode 100644 index 000000000..4b26e6463 --- /dev/null +++ b/src/graphics/draw/CompassRenderer.h @@ -0,0 +1,36 @@ +#pragma once + +#include "graphics/Screen.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +namespace graphics +{ + +/// Forward declarations +class Screen; + +/** + * @brief Compass and navigation drawing functions + * + * Contains all functions related to drawing compass elements, headings, + * navigation arrows, and location-based UI components. + */ +namespace CompassRenderer +{ +// Compass drawing functions +void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius); +void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian); +void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing); + +// Navigation and location functions +float estimatedHeading(double lat, double lon); +uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight); + +// Utility functions for bearing calculations +float calculateBearing(double lat1, double lon1, double lat2, double lon2); + +} // namespace CompassRenderer + +} // namespace graphics diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp new file mode 100644 index 000000000..2c3a3a3a8 --- /dev/null +++ b/src/graphics/draw/DebugRenderer.cpp @@ -0,0 +1,634 @@ +#include "configuration.h" +#if HAS_SCREEN +#include "../Screen.h" +#include "DebugRenderer.h" +#include "FSCommon.h" +#include "NodeDB.h" +#include "Throttle.h" +#include "UIRenderer.h" +#include "airtime.h" +#include "gps/RTC.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" +#include "main.h" +#include "mesh/Channels.h" +#include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "sleep.h" + +#if HAS_WIFI && !defined(ARCH_PORTDUINO) +#include "mesh/wifi/WiFiAPClient.h" +#include +#ifdef ARCH_ESP32 +#include "mesh/wifi/WiFiAPClient.h" +#endif +#endif + +#ifdef ARCH_ESP32 +#include "modules/StoreForwardModule.h" +#endif +#include +#include +#include + +using namespace meshtastic; + +// External variables +extern graphics::Screen *screen; +extern PowerStatus *powerStatus; +extern NodeStatus *nodeStatus; +extern GPSStatus *gpsStatus; +extern Channels channels; +extern AirTime *airTime; + +// External functions from Screen.cpp +extern bool heartbeat; + +#ifdef ARCH_ESP32 +extern StoreForwardModule *storeForwardModule; +#endif + +namespace graphics +{ +namespace DebugRenderer +{ + +void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setFont(FONT_SMALL); + + // The coordinates define the left starting point of the text + display->setTextAlignment(TEXT_ALIGN_LEFT); + + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); + display->setColor(BLACK); + } + + char channelStr[20]; + snprintf(channelStr, sizeof(channelStr), "#%s", channels.getName(channels.getPrimaryIndex())); + + // Display power status + if (powerStatus->getHasBattery()) { + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { + UIRenderer::drawBattery(display, x, y + 2, imgBattery, powerStatus); + } else { + UIRenderer::drawBattery(display, x + 1, y + 3, imgBattery, powerStatus); + } + } else if (powerStatus->knowsUSB()) { + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { + display->drawFastImage(x, y + 2, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); + } else { + display->drawFastImage(x + 1, y + 3, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); + } + } + // Display nodes status + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { + UIRenderer::drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 2, nodeStatus); + } else { + UIRenderer::drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 3, nodeStatus); + } +#if HAS_GPS + // Display GPS status + if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + UIRenderer::drawGpsPowerStatus(display, x, y + 2, gpsStatus); + } else { + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { + UIRenderer::drawGps(display, x + (SCREEN_WIDTH * 0.63), y + 2, gpsStatus); + } else { + UIRenderer::drawGps(display, x + (SCREEN_WIDTH * 0.63), y + 3, gpsStatus); + } + } +#endif + display->setColor(WHITE); + // Draw the channel name + display->drawString(x, y + FONT_HEIGHT_SMALL, channelStr); + // Draw our hardware ID to assist with bluetooth pairing. Either prefix with Info or S&F Logo + if (moduleConfig.store_forward.enabled) { +#ifdef ARCH_ESP32 + if (!Throttle::isWithinTimespanMs(storeForwardModule->lastHeartbeat, + (storeForwardModule->heartbeatInterval * 1200))) { // no heartbeat, overlap a bit +#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ + defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || ARCH_PORTDUINO) && \ + !defined(DISPLAY_FORCE_SMALL_FONTS) + display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 12, + 8, imgQuestionL1); + display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 11 + FONT_HEIGHT_SMALL, 12, + 8, imgQuestionL2); +#else + display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(screen->ourId), y + 2 + FONT_HEIGHT_SMALL, 8, + 8, imgQuestion); +#endif + } else { +#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ + defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS)) && \ + !defined(DISPLAY_FORCE_SMALL_FONTS) + display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 16, + 8, imgSFL1); + display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(screen->ourId), y + 11 + FONT_HEIGHT_SMALL, 16, + 8, imgSFL2); +#else + display->drawFastImage(x + SCREEN_WIDTH - 13 - display->getStringWidth(screen->ourId), y + 2 + FONT_HEIGHT_SMALL, 11, + 8, imgSF); +#endif + } +#endif + } else { + // TODO: Raspberry Pi supports more than just the one screen size +#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ + defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || ARCH_PORTDUINO) && \ + !defined(DISPLAY_FORCE_SMALL_FONTS) + display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8, + imgInfoL1); + display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(screen->ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8, + imgInfoL2); +#else + display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(screen->ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8, + imgInfo); +#endif + } + + display->drawString(x + SCREEN_WIDTH - display->getStringWidth(screen->ourId), y + FONT_HEIGHT_SMALL, screen->ourId); + + // Draw any log messages + display->drawLogBuffer(x, y + (FONT_HEIGHT_SMALL * 2)); + + /* Display a heartbeat pixel that blinks every time the frame is redrawn */ +#ifdef SHOW_REDRAWS + if (heartbeat) + display->setPixel(0, 0); + heartbeat = !heartbeat; +#endif +} + +// **************************** +// * WiFi Screen * +// **************************** +void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ +#if HAS_WIFI && !defined(ARCH_PORTDUINO) + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + int line = 1; + + // === Set Title + const char *titleStr = "WiFi"; + + // === Header === + graphics::drawCommonHeader(display, x, y, titleStr); + + const char *wifiName = config.network.wifi_ssid; + + if (WiFi.status() != WL_CONNECTED) { + display->drawString(x, getTextPositions(display)[line++], "WiFi: Not Connected"); + } else { + display->drawString(x, getTextPositions(display)[line++], "WiFi: Connected"); + + char rssiStr[32]; + snprintf(rssiStr, sizeof(rssiStr), "RSSI: %d", WiFi.RSSI()); + display->drawString(x, getTextPositions(display)[line++], rssiStr); + } + + /* + - WL_CONNECTED: assigned when connected to a WiFi network; + - WL_NO_SSID_AVAIL: assigned when no SSID are available; + - WL_CONNECT_FAILED: assigned when the connection fails for all the attempts; + - WL_CONNECTION_LOST: assigned when the connection is lost; + - WL_DISCONNECTED: assigned when disconnected from a network; + - WL_IDLE_STATUS: it is a temporary status assigned when WiFi.begin() is called and remains active until the number of + attempts expires (resulting in WL_CONNECT_FAILED) or a connection is established (resulting in WL_CONNECTED); + - WL_SCAN_COMPLETED: assigned when the scan networks is completed; + - WL_NO_SHIELD: assigned when no WiFi shield is present; + + */ + if (WiFi.status() == WL_CONNECTED) { + char ipStr[64]; + snprintf(ipStr, sizeof(ipStr), "IP: %s", WiFi.localIP().toString().c_str()); + display->drawString(x, getTextPositions(display)[line++], ipStr); + } else if (WiFi.status() == WL_NO_SSID_AVAIL) { + display->drawString(x, getTextPositions(display)[line++], "SSID Not Found"); + } else if (WiFi.status() == WL_CONNECTION_LOST) { + display->drawString(x, getTextPositions(display)[line++], "Connection Lost"); + } else if (WiFi.status() == WL_IDLE_STATUS) { + display->drawString(x, getTextPositions(display)[line++], "Idle ... Reconnecting"); + } else if (WiFi.status() == WL_CONNECT_FAILED) { + display->drawString(x, getTextPositions(display)[line++], "Connection Failed"); + } +#ifdef ARCH_ESP32 + else { + // Codes: + // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/wifi.html#wi-fi-reason-code + display->drawString(x, getTextPositions(display)[line++], + WiFi.disconnectReasonName(static_cast(getWifiDisconnectReason()))); + } +#else + else { + char statusStr[32]; + snprintf(statusStr, sizeof(statusStr), "Unknown status: %d", WiFi.status()); + display->drawString(x, getTextPositions(display)[line++], statusStr); + } +#endif + + char ssidStr[64]; + snprintf(ssidStr, sizeof(ssidStr), "SSID: %s", wifiName); + display->drawString(x, getTextPositions(display)[line++], ssidStr); + + display->drawString(x, getTextPositions(display)[line++], "URL: http://meshtastic.local"); + + /* Display a heartbeat pixel that blinks every time the frame is redrawn */ +#ifdef SHOW_REDRAWS + if (heartbeat) + display->setPixel(0, 0); + heartbeat = !heartbeat; +#endif +#endif +} + +void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setFont(FONT_SMALL); + + // The coordinates define the left starting point of the text + display->setTextAlignment(TEXT_ALIGN_LEFT); + + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); + display->setColor(BLACK); + } + + char batStr[20]; + if (powerStatus->getHasBattery()) { + int batV = powerStatus->getBatteryVoltageMv() / 1000; + int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; + + snprintf(batStr, sizeof(batStr), "B %01d.%02dV %3d%% %c%c", batV, batCv, powerStatus->getBatteryChargePercent(), + powerStatus->getIsCharging() ? '+' : ' ', powerStatus->getHasUSB() ? 'U' : ' '); + + // Line 1 + display->drawString(x, y, batStr); + if (config.display.heading_bold) + display->drawString(x + 1, y, batStr); + } else { + // Line 1 + display->drawString(x, y, "USB"); + if (config.display.heading_bold) + display->drawString(x + 1, y, "USB"); + } + + // auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, true); + + // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode), y, mode); + // if (config.display.heading_bold) + // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode) - 1, y, mode); + + uint32_t currentMillis = millis(); + uint32_t seconds = currentMillis / 1000; + uint32_t minutes = seconds / 60; + uint32_t hours = minutes / 60; + uint32_t days = hours / 24; + // currentMillis %= 1000; + // seconds %= 60; + // minutes %= 60; + // hours %= 24; + + // Show uptime as days, hours, minutes OR seconds + std::string uptime = UIRenderer::drawTimeDelta(days, hours, minutes, seconds); + + // Line 1 (Still) + display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); + if (config.display.heading_bold) + display->drawString(x - 1 + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); + + display->setColor(WHITE); + + // Setup string to assemble analogClock string + std::string analogClock = ""; + + uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone + if (rtc_sec > 0) { + long hms = rtc_sec % SEC_PER_DAY; + // hms += tz.tz_dsttime * SEC_PER_HOUR; + // hms -= tz.tz_minuteswest * SEC_PER_MIN; + // mod `hms` to ensure in positive range of [0...SEC_PER_DAY) + hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; + + // Tear apart hms into h:m:s + int hour = hms / SEC_PER_HOUR; + int min = (hms % SEC_PER_HOUR) / SEC_PER_MIN; + int sec = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN + + char timebuf[12]; + + if (config.display.use_12h_clock) { + std::string meridiem = "am"; + if (hour >= 12) { + if (hour > 12) + hour -= 12; + meridiem = "pm"; + } + if (hour == 00) { + hour = 12; + } + snprintf(timebuf, sizeof(timebuf), "%d:%02d:%02d%s", hour, min, sec, meridiem.c_str()); + } else { + snprintf(timebuf, sizeof(timebuf), "%02d:%02d:%02d", hour, min, sec); + } + analogClock += timebuf; + } + + // Line 2 + display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str()); + + // Display Channel Utilization + char chUtil[13]; + snprintf(chUtil, sizeof(chUtil), "ChUtil %2.0f%%", airTime->channelUtilizationPercent()); + display->drawString(x + SCREEN_WIDTH - display->getStringWidth(chUtil), y + FONT_HEIGHT_SMALL * 1, chUtil); + +#if HAS_GPS + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + // Line 3 + if (config.display.gps_format != + meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) // if DMS then don't draw altitude + UIRenderer::drawGpsAltitude(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); + + // Line 4 + UIRenderer::drawGpsCoordinates(display, x, y + FONT_HEIGHT_SMALL * 3, gpsStatus); + } else { + UIRenderer::drawGpsPowerStatus(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); + } +#endif +/* Display a heartbeat pixel that blinks every time the frame is redrawn */ +#ifdef SHOW_REDRAWS + if (heartbeat) + display->setPixel(0, 0); + heartbeat = !heartbeat; +#endif +} + +// Trampoline functions for DebugInfo class access +void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + drawFrame(display, state, x, y); +} + +void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + drawFrameSettings(display, state, x, y); +} + +void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + drawFrameWiFi(display, state, x, y); +} + +// **************************** +// * LoRa Focused Screen * +// **************************** +void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + int line = 1; + + // === Set Title + const char *titleStr = (SCREEN_WIDTH > 128) ? "LoRa Info" : "LoRa"; + + // === Header === + graphics::drawCommonHeader(display, x, y, titleStr); + + // === First Row: Region / BLE Name === + graphics::UIRenderer::drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, 0, true, ""); + + uint8_t dmac[6]; + char shortnameble[35]; + getMacAddr(dmac); + snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]); + snprintf(shortnameble, sizeof(shortnameble), "BLE: %s", screen->ourId); + int textWidth = display->getStringWidth(shortnameble); + int nameX = (SCREEN_WIDTH - textWidth); + display->drawString(nameX, getTextPositions(display)[line++], shortnameble); + + // === Second Row: Radio Preset === + auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false); + char regionradiopreset[25]; + const char *region = myRegion ? myRegion->name : NULL; + if (region != nullptr) { + snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode); + } + textWidth = display->getStringWidth(regionradiopreset); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, getTextPositions(display)[line++], regionradiopreset); + + // === Third Row: Frequency / ChanNum === + char frequencyslot[35]; + char freqStr[16]; + float freq = RadioLibInterface::instance->getFreq(); + snprintf(freqStr, sizeof(freqStr), "%.3f", freq); + if (config.lora.channel_num == 0) { + snprintf(frequencyslot, sizeof(frequencyslot), "Freq: %smhz", freqStr); + } else { + snprintf(frequencyslot, sizeof(frequencyslot), "Freq/Ch: %smhz (%d)", freqStr, config.lora.channel_num); + } + size_t len = strlen(frequencyslot); + if (len >= 4 && strcmp(frequencyslot + len - 4, " (0)") == 0) { + frequencyslot[len - 4] = '\0'; // Remove the last three characters + } + textWidth = display->getStringWidth(frequencyslot); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, getTextPositions(display)[line++], frequencyslot); + + // === Fourth Row: Channel Utilization === + const char *chUtil = "ChUtil:"; + char chUtilPercentage[10]; + snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent()); + + int chUtil_x = (SCREEN_WIDTH > 128) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5; + int chUtil_y = getTextPositions(display)[line] + 3; + + int chutil_bar_width = (SCREEN_WIDTH > 128) ? 100 : 50; + int chutil_bar_height = (SCREEN_WIDTH > 128) ? 12 : 7; + int extraoffset = (SCREEN_WIDTH > 128) ? 6 : 3; + int chutil_percent = airTime->channelUtilizationPercent(); + + int centerofscreen = SCREEN_WIDTH / 2; + int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; + int starting_position = centerofscreen - total_line_content_width; + + display->drawString(starting_position, getTextPositions(display)[line++], chUtil); + + // Force 56% or higher to show a full 100% bar, text would still show related percent. + if (chutil_percent >= 61) { + chutil_percent = 100; + } + + // Weighting for nonlinear segments + float milestone1 = 25; + float milestone2 = 40; + float weight1 = 0.45; // Weight for 0–25% + float weight2 = 0.35; // Weight for 25–40% + float weight3 = 0.20; // Weight for 40–100% + float totalWeight = weight1 + weight2 + weight3; + + int seg1 = chutil_bar_width * (weight1 / totalWeight); + int seg2 = chutil_bar_width * (weight2 / totalWeight); + int seg3 = chutil_bar_width * (weight3 / totalWeight); + + int fillRight = 0; + + if (chutil_percent <= milestone1) { + fillRight = (seg1 * (chutil_percent / milestone1)); + } else if (chutil_percent <= milestone2) { + fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1))); + } else { + fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2))); + } + + // Draw outline + display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height); + + // Fill progress + if (fillRight > 0) { + display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height); + } + + display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[4], + chUtilPercentage); +} + +// **************************** +// * Memory Screen * +// **************************** +void drawMemoryUsage(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + // === Set Title + const char *titleStr = "System"; + + // === Header === + graphics::drawCommonHeader(display, x, y, titleStr); + + // === Layout === + int line = 1; + const int barHeight = 6; + const int labelX = x; + const int barsOffset = (SCREEN_WIDTH > 128) ? 24 : 0; + const int barX = x + 40 + barsOffset; + + auto drawUsageRow = [&](const char *label, uint32_t used, uint32_t total, bool isHeap = false) { + if (total == 0) + return; + + int percent = (used * 100) / total; + + char combinedStr[24]; + if (SCREEN_WIDTH > 128) { + snprintf(combinedStr, sizeof(combinedStr), "%s%3d%% %u/%uKB", (percent > 80) ? "! " : "", percent, used / 1024, + total / 1024); + } else { + snprintf(combinedStr, sizeof(combinedStr), "%s%3d%%", (percent > 80) ? "! " : "", percent); + } + + int textWidth = display->getStringWidth(combinedStr); + int adjustedBarWidth = SCREEN_WIDTH - barX - textWidth - 6; + if (adjustedBarWidth < 10) + adjustedBarWidth = 10; + + int fillWidth = (used * adjustedBarWidth) / total; + + // Label + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawString(labelX, getTextPositions(display)[line], label); + + // Bar + int barY = getTextPositions(display)[line] + (FONT_HEIGHT_SMALL - barHeight) / 2; + display->setColor(WHITE); + display->drawRect(barX, barY, adjustedBarWidth, barHeight); + + display->fillRect(barX, barY, fillWidth, barHeight); + display->setColor(WHITE); + + // Value string + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(SCREEN_WIDTH - 2, getTextPositions(display)[line], combinedStr); + }; + + // === Memory values === + uint32_t heapUsed = memGet.getHeapSize() - memGet.getFreeHeap(); + uint32_t heapTotal = memGet.getHeapSize(); + + uint32_t psramUsed = memGet.getPsramSize() - memGet.getFreePsram(); + uint32_t psramTotal = memGet.getPsramSize(); + + uint32_t flashUsed = 0, flashTotal = 0; +#ifdef ESP32 + flashUsed = FSCom.usedBytes(); + flashTotal = FSCom.totalBytes(); +#endif + + uint32_t sdUsed = 0, sdTotal = 0; + bool hasSD = false; + /* + #ifdef HAS_SDCARD + hasSD = SD.cardType() != CARD_NONE; + if (hasSD) { + sdUsed = SD.usedBytes(); + sdTotal = SD.totalBytes(); + } + #endif + */ + // === Draw memory rows + drawUsageRow("Heap:", heapUsed, heapTotal, true); +#ifdef ESP32 + if (psramUsed > 0) { + line += 1; + drawUsageRow("PSRAM:", psramUsed, psramTotal); + } + if (flashTotal > 0) { + line += 1; + drawUsageRow("Flash:", flashUsed, flashTotal); + } +#endif + if (hasSD && sdTotal > 0) { + line += 1; + drawUsageRow("SD:", sdUsed, sdTotal); + } + + display->setTextAlignment(TEXT_ALIGN_LEFT); + // System Uptime + if (line < 2) { + line += 1; + } + line += 1; + char appversionstr[35]; + snprintf(appversionstr, sizeof(appversionstr), "Ver.: %s", optstr(APP_VERSION)); + int textWidth = display->getStringWidth(appversionstr); + int nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, getTextPositions(display)[line], appversionstr); + + if (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line < 4)) { // Only show uptime if the screen can show it + line += 1; + char uptimeStr[32] = ""; + uint32_t uptime = millis() / 1000; + uint32_t days = uptime / 86400; + uint32_t hours = (uptime % 86400) / 3600; + uint32_t mins = (uptime % 3600) / 60; + // Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m" + if (days) + snprintf(uptimeStr, sizeof(uptimeStr), " Up: %ud %uh", days, hours); + else if (hours) + snprintf(uptimeStr, sizeof(uptimeStr), " Up: %uh %um", hours, mins); + else + snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %um", mins); + textWidth = display->getStringWidth(uptimeStr); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, getTextPositions(display)[line], uptimeStr); + } +} +} // namespace DebugRenderer +} // namespace graphics +#endif \ No newline at end of file diff --git a/src/graphics/draw/DebugRenderer.h b/src/graphics/draw/DebugRenderer.h new file mode 100644 index 000000000..f4d484f58 --- /dev/null +++ b/src/graphics/draw/DebugRenderer.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace graphics +{ + +/// Forward declarations +class Screen; +class DebugInfo; + +/** + * @brief Debug and diagnostic drawing functions + * + * Contains all functions related to drawing debug information, + * WiFi status, settings screens, and diagnostic data. + */ +namespace DebugRenderer +{ +// Debug frame functions +void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// Trampoline functions for framework callback compatibility +void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// LoRa information display +void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// Memory screen display +void drawMemoryUsage(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +} // namespace DebugRenderer + +} // namespace graphics diff --git a/src/graphics/draw/DrawRenderers.h b/src/graphics/draw/DrawRenderers.h new file mode 100644 index 000000000..6f1929ebd --- /dev/null +++ b/src/graphics/draw/DrawRenderers.h @@ -0,0 +1,38 @@ +#pragma once + +/** + * @brief Master include file for all Screen draw renderers + * + * This file includes all the individual renderer headers to provide + * a convenient single include for accessing all draw functions. + */ + +#include "graphics/draw/ClockRenderer.h" +#include "graphics/draw/CompassRenderer.h" +#include "graphics/draw/DebugRenderer.h" +#include "graphics/draw/NodeListRenderer.h" +#include "graphics/draw/ScreenRenderer.h" +#include "graphics/draw/UIRenderer.h" + +namespace graphics +{ + +/** + * @brief Collection of all draw renderers + * + * This namespace provides access to all the specialized rendering + * functions organized by category. + */ +namespace DrawRenderers +{ +// Re-export all renderer namespaces for convenience +using namespace ClockRenderer; +using namespace CompassRenderer; +using namespace DebugRenderer; +using namespace NodeListRenderer; +using namespace ScreenRenderer; +using namespace UIRenderer; + +} // namespace DrawRenderers + +} // namespace graphics diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp new file mode 100644 index 000000000..707517d82 --- /dev/null +++ b/src/graphics/draw/MessageRenderer.cpp @@ -0,0 +1,392 @@ +/* +BaseUI + +Developed and Maintained By: +- Ronald Garcia (HarukiToreda) – Lead development and implementation. +- JasonP (Xaositek) – Screen layout and icon design, UI improvements and testing. +- TonyG (Tropho) – Project management, structural planning, and testing + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +*/ + +#include "configuration.h" +#if HAS_SCREEN +#include "MessageRenderer.h" + +// Core includes +#include "NodeDB.h" +#include "configuration.h" +#include "gps/RTC.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/emotes.h" +#include "main.h" +#include "meshUtils.h" + +// Additional includes for UI rendering +#include "UIRenderer.h" +#include "graphics/TimeFormatters.h" + +// Additional includes for dependencies +#include +#include + +// External declarations +extern bool hasUnreadMessage; +extern meshtastic_DeviceState devicestate; + +using graphics::Emote; +using graphics::emotes; +using graphics::numEmotes; + +namespace graphics +{ +namespace MessageRenderer +{ + +void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount) +{ + int cursorX = x; + const int fontHeight = FONT_HEIGHT_SMALL; + + // === Step 1: Find tallest emote in the line === + int maxIconHeight = fontHeight; + for (size_t i = 0; i < line.length();) { + bool matched = false; + for (int e = 0; e < emoteCount; ++e) { + size_t emojiLen = strlen(emotes[e].label); + if (line.compare(i, emojiLen, emotes[e].label) == 0) { + if (emotes[e].height > maxIconHeight) + maxIconHeight = emotes[e].height; + i += emojiLen; + matched = true; + break; + } + } + if (!matched) { + uint8_t c = static_cast(line[i]); + if ((c & 0xE0) == 0xC0) + i += 2; + else if ((c & 0xF0) == 0xE0) + i += 3; + else if ((c & 0xF8) == 0xF0) + i += 4; + else + i += 1; + } + } + + // === Step 2: Baseline alignment === + int lineHeight = std::max(fontHeight, maxIconHeight); + int baselineOffset = (lineHeight - fontHeight) / 2; + int fontY = y + baselineOffset; + int fontMidline = fontY + fontHeight / 2; + + // === Step 3: Render line in segments === + size_t i = 0; + bool inBold = false; + + while (i < line.length()) { + // Check for ** start/end for faux bold + if (line.compare(i, 2, "**") == 0) { + inBold = !inBold; + i += 2; + continue; + } + + // Look ahead for the next emote match + size_t nextEmotePos = std::string::npos; + const Emote *matchedEmote = nullptr; + size_t emojiLen = 0; + + for (int e = 0; e < emoteCount; ++e) { + size_t pos = line.find(emotes[e].label, i); + if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) { + nextEmotePos = pos; + matchedEmote = &emotes[e]; + emojiLen = strlen(emotes[e].label); + } + } + + // Render normal text segment up to the emote or bold toggle + size_t nextControl = std::min(nextEmotePos, line.find("**", i)); + if (nextControl == std::string::npos) + nextControl = line.length(); + + if (nextControl > i) { + std::string textChunk = line.substr(i, nextControl - i); + if (inBold) { + // Faux bold: draw twice, offset by 1px + display->drawString(cursorX + 1, fontY, textChunk.c_str()); + } + display->drawString(cursorX, fontY, textChunk.c_str()); + cursorX += display->getStringWidth(textChunk.c_str()); + i = nextControl; + continue; + } + + // Render the emote (if found) + if (matchedEmote && i == nextEmotePos) { + int iconY = fontMidline - matchedEmote->height / 2 - 1; + display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap); + cursorX += matchedEmote->width + 1; + i += emojiLen; + } else { + // No more emotes — render the rest of the line + std::string remaining = line.substr(i); + if (inBold) { + display->drawString(cursorX + 1, fontY, remaining.c_str()); + } + display->drawString(cursorX, fontY, remaining.c_str()); + cursorX += display->getStringWidth(remaining.c_str()); + break; + } + } +} + +void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + // Clear the unread message indicator when viewing the message + hasUnreadMessage = false; + + const meshtastic_MeshPacket &mp = devicestate.rx_text_message; + const char *msg = reinterpret_cast(mp.decoded.payload.bytes); + + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + const int navHeight = FONT_HEIGHT_SMALL; + const int scrollBottom = SCREEN_HEIGHT - navHeight; + const int usableHeight = scrollBottom; + const int textWidth = SCREEN_WIDTH; + + bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED); + bool isBold = config.display.heading_bold; + + // === Set Title + const char *titleStr = "Messages"; + + // Check if we have more than an empty message to show + char messageBuf[237]; + snprintf(messageBuf, sizeof(messageBuf), "%s", msg); + if (strlen(messageBuf) == 0) { + // === Header === + graphics::drawCommonHeader(display, x, y, titleStr); + const char *messageString = "No messages"; + int center_text = (SCREEN_WIDTH / 2) - (display->getStringWidth(messageString) / 2); + display->drawString(center_text, getTextPositions(display)[2], messageString); + return; + } + + // === Header Construction === + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp)); + char headerStr[80]; + const char *sender = "???"; + if (node && node->has_user) { + if (SCREEN_WIDTH >= 200 && strlen(node->user.long_name) > 0) { + sender = node->user.long_name; + } else { + sender = node->user.short_name; + } + } + uint32_t seconds = sinceReceived(&mp), minutes = seconds / 60, hours = minutes / 60, days = hours / 24; + uint8_t timestampHours, timestampMinutes; + int32_t daysAgo; + bool useTimestamp = deltaToTimestamp(seconds, ×tampHours, ×tampMinutes, &daysAgo); + + if (useTimestamp && minutes >= 15 && daysAgo == 0) { + std::string prefix = (daysAgo == 1 && SCREEN_WIDTH >= 200) ? "Yesterday" : "At"; + if (config.display.use_12h_clock) { + bool isPM = timestampHours >= 12; + timestampHours = timestampHours % 12; + if (timestampHours == 0) + timestampHours = 12; + snprintf(headerStr, sizeof(headerStr), "%s %d:%02d%s from %s", prefix.c_str(), timestampHours, timestampMinutes, + isPM ? "p" : "a", sender); + } else { + snprintf(headerStr, sizeof(headerStr), "%s %d:%02d from %s", prefix.c_str(), timestampHours, timestampMinutes, + sender); + } + } else { + snprintf(headerStr, sizeof(headerStr), "%s ago from %s", UIRenderer::drawTimeDelta(days, hours, minutes, seconds).c_str(), + sender); + } + +#ifndef EXCLUDE_EMOJI + // === Bounce animation setup === + static uint32_t lastBounceTime = 0; + static int bounceY = 0; + const int bounceRange = 2; // Max pixels to bounce up/down + const int bounceInterval = 10; // How quickly to change bounce direction (ms) + + uint32_t now = millis(); + if (now - lastBounceTime >= bounceInterval) { + lastBounceTime = now; + bounceY = (bounceY + 1) % (bounceRange * 2); + } + for (int i = 0; i < numEmotes; ++i) { + const Emote &e = emotes[i]; + if (strcmp(msg, e.label) == 0) { + int headerY = getTextPositions(display)[1]; // same as scrolling header line + display->drawString(x + 3, headerY, headerStr); + if (isInverted && isBold) + display->drawString(x + 4, headerY, headerStr); + + // Draw separator (same as scroll version) + for (int separatorX = 0; separatorX <= (display->getStringWidth(headerStr) + 3); separatorX += 2) { + display->setPixel(separatorX, headerY + ((SCREEN_WIDTH > 128) ? 19 : 13)); + } + + // Center the emote below the header line + separator + nav + int remainingHeight = SCREEN_HEIGHT - (headerY + FONT_HEIGHT_SMALL) - navHeight; + int emoteY = headerY + FONT_HEIGHT_SMALL + (remainingHeight - e.height) / 2 + bounceY - bounceRange; + display->drawXbm((SCREEN_WIDTH - e.width) / 2, emoteY, e.width, e.height, e.bitmap); + return; + } + } +#endif + + // === Word-wrap and build line list === + std::vector lines; + lines.push_back(std::string(headerStr)); // Header line is always first + + std::string line, word; + for (int i = 0; messageBuf[i]; ++i) { + char ch = messageBuf[i]; + if (ch == '\n') { + if (!word.empty()) + line += word; + if (!line.empty()) + lines.push_back(line); + line.clear(); + word.clear(); + } else if (ch == ' ') { + line += word + ' '; + word.clear(); + } else { + word += ch; + std::string test = line + word; + if (display->getStringWidth(test.c_str()) > textWidth) { + if (!line.empty()) + lines.push_back(line); + line = word; + word.clear(); + } + } + } + if (!word.empty()) + line += word; + if (!line.empty()) + lines.push_back(line); + + // === Scrolling logic === + std::vector rowHeights; + + for (const auto &_line : lines) { + int lineHeight = FONT_HEIGHT_SMALL; + bool hasEmote = false; + + for (int i = 0; i < numEmotes; ++i) { + const Emote &e = emotes[i]; + if (_line.find(e.label) != std::string::npos) { + lineHeight = std::max(lineHeight, e.height); + hasEmote = true; + } + } + + // Apply tighter spacing if no emotes on this line + if (!hasEmote) { + lineHeight -= 2; // reduce by 2px for tighter spacing + if (lineHeight < 8) + lineHeight = 8; // minimum safety + } + + rowHeights.push_back(lineHeight); + } + int totalHeight = 0; + for (size_t i = 1; i < rowHeights.size(); ++i) { + totalHeight += rowHeights[i]; + } + int usableScrollHeight = usableHeight - rowHeights[0]; // remove header height + int scrollStop = std::max(0, totalHeight - usableScrollHeight + rowHeights.back()); + + static float scrollY = 0.0f; + static uint32_t lastTime = 0, scrollStartDelay = 0, pauseStart = 0; + static bool waitingToReset = false, scrollStarted = false; + + // === Smooth scrolling adjustment === + // You can tweak this divisor to change how smooth it scrolls. + // Lower = smoother, but can feel slow. + float delta = (now - lastTime) / 400.0f; + lastTime = now; + + const float scrollSpeed = 2.0f; // pixels per second + + // Delay scrolling start by 2 seconds + if (scrollStartDelay == 0) + scrollStartDelay = now; + if (!scrollStarted && now - scrollStartDelay > 2000) + scrollStarted = true; + + if (totalHeight > usableScrollHeight) { + if (scrollStarted) { + if (!waitingToReset) { + scrollY += delta * scrollSpeed; + if (scrollY >= scrollStop) { + scrollY = scrollStop; + waitingToReset = true; + pauseStart = lastTime; + } + } else if (lastTime - pauseStart > 3000) { + scrollY = 0; + waitingToReset = false; + scrollStarted = false; + scrollStartDelay = lastTime; + } + } + } else { + scrollY = 0; + } + + int scrollOffset = static_cast(scrollY); + int yOffset = -scrollOffset + getTextPositions(display)[1]; + for (int separatorX = 0; separatorX <= (display->getStringWidth(headerStr) + 3); separatorX += 2) { + display->setPixel(separatorX, yOffset + ((SCREEN_WIDTH > 128) ? 19 : 13)); + } + + // === Render visible lines === + for (size_t i = 0; i < lines.size(); ++i) { + int lineY = yOffset; + for (size_t j = 0; j < i; ++j) + lineY += rowHeights[j]; + if (lineY > -rowHeights[i] && lineY < scrollBottom) { + if (i == 0 && isInverted) { + display->drawString(x + 3, lineY, lines[i].c_str()); + if (isBold) + display->drawString(x + 4, lineY, lines[i].c_str()); + } else { + drawStringWithEmotes(display, x, lineY, lines[i], emotes, numEmotes); + } + } + } + + // Draw header at the end to sort out overlapping elements + graphics::drawCommonHeader(display, x, y, titleStr); +} + +} // namespace MessageRenderer +} // namespace graphics +#endif \ No newline at end of file diff --git a/src/graphics/draw/MessageRenderer.h b/src/graphics/draw/MessageRenderer.h new file mode 100644 index 000000000..d92b96014 --- /dev/null +++ b/src/graphics/draw/MessageRenderer.h @@ -0,0 +1,18 @@ +#pragma once +#include "OLEDDisplay.h" +#include "OLEDDisplayUi.h" +#include "graphics/emotes.h" + +namespace graphics +{ +namespace MessageRenderer +{ + +// Text and emote rendering +void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount); + +/// Draws the text message frame for displaying received messages +void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +} // namespace MessageRenderer +} // namespace graphics diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp new file mode 100644 index 000000000..13b71546e --- /dev/null +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -0,0 +1,595 @@ +#include "configuration.h" +#if HAS_SCREEN +#include "CompassRenderer.h" +#include "NodeDB.h" +#include "NodeListRenderer.h" +#include "UIRenderer.h" +#include "gps/GeoCoord.h" +#include "gps/RTC.h" // for getTime() function +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" +#include "meshUtils.h" +#include + +// Forward declarations for functions defined in Screen.cpp +namespace graphics +{ +extern bool haveGlyphs(const char *str); +} // namespace graphics + +// Global screen instance +extern graphics::Screen *screen; + +namespace graphics +{ +namespace NodeListRenderer +{ + +// Function moved from Screen.cpp to NodeListRenderer.cpp since it's primarily used here +void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display) +{ + for (int row = 0; row < height; row++) { + uint8_t rowMask = (1 << row); + for (int col = 0; col < width; col++) { + uint8_t colData = pgm_read_byte(&bitmapXBM[col]); + if (colData & rowMask) { + // Note: rows become X, columns become Y after transpose + display->fillRect(x + row * 2, y + col * 2, 2, 2); + } + } + } +} + +// Static variables for dynamic cycling +static NodeListMode currentMode = MODE_LAST_HEARD; +static int scrollIndex = 0; + +// ============================= +// Utility Functions +// ============================= + +const char *getSafeNodeName(meshtastic_NodeInfoLite *node) +{ + static char nodeName[16] = "?"; + if (node->has_user && strlen(node->user.short_name) > 0) { + bool valid = true; + const char *name = node->user.short_name; + for (size_t i = 0; i < strlen(name); i++) { + uint8_t c = (uint8_t)name[i]; + if (c < 32 || c > 126) { + valid = false; + break; + } + } + if (valid) { + strncpy(nodeName, name, sizeof(nodeName) - 1); + nodeName[sizeof(nodeName) - 1] = '\0'; + } else { + snprintf(nodeName, sizeof(nodeName), "%04X", (uint16_t)(node->num & 0xFFFF)); + } + } else { + strcpy(nodeName, "?"); + } + return nodeName; +} + +const char *getCurrentModeTitle(int screenWidth) +{ + switch (currentMode) { + case MODE_LAST_HEARD: + return "Last Heard"; + case MODE_HOP_SIGNAL: + return (screenWidth > 128) ? "Hops/Signal" : "Hops/Sig"; + case MODE_DISTANCE: + return "Distance"; + default: + return "Nodes"; + } +} + +// Use dynamic timing based on mode +unsigned long getModeCycleIntervalMs() +{ + return 3000; +} + +// Calculate bearing between two lat/lon points +float calculateBearing(double lat1, double lon1, double lat2, double lon2) +{ + double dLon = (lon2 - lon1) * DEG_TO_RAD; + double y = sin(dLon) * cos(lat2 * DEG_TO_RAD); + double x = cos(lat1 * DEG_TO_RAD) * sin(lat2 * DEG_TO_RAD) - sin(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * cos(dLon); + double bearing = atan2(y, x) * RAD_TO_DEG; + return fmod(bearing + 360.0, 360.0); +} + +int calculateMaxScroll(int totalEntries, int visibleRows) +{ + return std::max(0, (totalEntries - 1) / (visibleRows * 2)); +} + +void retrieveAndSortNodes(std::vector &nodeList) +{ + size_t numNodes = nodeDB->getNumMeshNodes(); + for (size_t i = 0; i < numNodes; i++) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + if (!node || node->num == nodeDB->getNodeNum()) + continue; + + NodeEntry entry; + entry.node = node; + entry.sortValue = sinceLastSeen(node); + + nodeList.push_back(entry); + } + + // Sort nodes: favorites first, then by last heard (most recent first) + std::sort(nodeList.begin(), nodeList.end(), [](const NodeEntry &a, const NodeEntry &b) { + bool aFav = a.node->is_favorite; + bool bFav = b.node->is_favorite; + if (aFav != bFav) + return aFav; + if (a.sortValue == 0 || a.sortValue == UINT32_MAX) + return false; + if (b.sortValue == 0 || b.sortValue == UINT32_MAX) + return true; + return a.sortValue < b.sortValue; + }); +} + +void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd) +{ + int columnWidth = display->getWidth() / 2; + int separatorX = x + columnWidth - 2; + for (int y = yStart; y <= yEnd; y += 2) { + display->setPixel(separatorX, y); + } +} + +void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, int scrollIndex, int columns, int scrollStartY) +{ + if (totalEntries <= visibleNodeRows * columns) + return; + + int scrollbarX = display->getWidth() - 2; + int scrollbarHeight = display->getHeight() - scrollStartY - 10; + int thumbHeight = std::max(4, (scrollbarHeight * visibleNodeRows * columns) / totalEntries); + int maxScroll = calculateMaxScroll(totalEntries, visibleNodeRows); + int thumbY = scrollStartY + (scrollIndex * (scrollbarHeight - thumbHeight)) / std::max(1, maxScroll); + + for (int i = 0; i < thumbHeight; i++) { + display->setPixel(scrollbarX, thumbY + i); + } +} + +// ============================= +// Entry Renderers +// ============================= + +void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + bool isLeftCol = (x < SCREEN_WIDTH / 2); + int timeOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7); + + const char *nodeName = getSafeNodeName(node); + + char timeStr[10]; + uint32_t seconds = sinceLastSeen(node); + if (seconds == 0 || seconds == UINT32_MAX) { + snprintf(timeStr, sizeof(timeStr), "?"); + } else { + uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24; + snprintf(timeStr, sizeof(timeStr), (days > 365 ? "?" : "%d%c"), + (days ? days + : hours ? hours + : minutes), + (days ? 'd' + : hours ? 'h' + : 'm')); + } + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawString(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nodeName); + if (node->is_favorite) { + if (SCREEN_WIDTH > 128) { + drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); + } else { + display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); + } + } + + int rightEdge = x + columnWidth - timeOffset; + if (timeStr[strlen(timeStr) - 1] == 'm') // Fix the fact that our fonts don't line up well all the time + rightEdge -= 1; + int textWidth = display->getStringWidth(timeStr); + display->drawString(rightEdge - textWidth, y, timeStr); +} + +void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + bool isLeftCol = (x < SCREEN_WIDTH / 2); + + int nameMaxWidth = columnWidth - 25; + int barsOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19); + int hopOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17); + + int barsXOffset = columnWidth - barsOffset; + + const char *nodeName = getSafeNodeName(node); + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nameMaxWidth, nodeName); + if (node->is_favorite) { + if (SCREEN_WIDTH > 128) { + drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); + } else { + display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); + } + } + + // Draw signal strength bars + int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0; + int barWidth = 2; + int barStartX = x + barsXOffset; + int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2; + + for (int b = 0; b < 4; b++) { + if (b < bars) { + int height = (b * 2); + display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height); + } + } + + // Draw hop count + char hopStr[6] = ""; + if (node->has_hops_away && node->hops_away > 0) + snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away); + + if (hopStr[0] != '\0') { + int rightEdge = x + columnWidth - hopOffset; + int textWidth = display->getStringWidth(hopStr); + display->drawString(rightEdge - textWidth, y, hopStr); + } +} + +void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + bool isLeftCol = (x < SCREEN_WIDTH / 2); + int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); + + const char *nodeName = getSafeNodeName(node); + char distStr[10] = ""; + + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) { + double lat1 = ourNode->position.latitude_i * 1e-7; + double lon1 = ourNode->position.longitude_i * 1e-7; + double lat2 = node->position.latitude_i * 1e-7; + double lon2 = node->position.longitude_i * 1e-7; + + double earthRadiusKm = 6371.0; + double dLat = (lat2 - lat1) * DEG_TO_RAD; + double dLon = (lon2 - lon1) * DEG_TO_RAD; + + double a = + sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2); + double c = 2 * atan2(sqrt(a), sqrt(1 - a)); + double distanceKm = earthRadiusKm * c; + + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { + double miles = distanceKm * 0.621371; + if (miles < 0.1) { + int feet = (int)(miles * 5280); + if (feet < 1000) + snprintf(distStr, sizeof(distStr), "%dft", feet); + else + snprintf(distStr, sizeof(distStr), "¼mi"); // 4-char max + } else { + int roundedMiles = (int)(miles + 0.5); + if (roundedMiles < 1000) + snprintf(distStr, sizeof(distStr), "%dmi", roundedMiles); + else + snprintf(distStr, sizeof(distStr), "999"); // Max display cap + } + } else { + if (distanceKm < 1.0) { + int meters = (int)(distanceKm * 1000); + if (meters < 1000) + snprintf(distStr, sizeof(distStr), "%dm", meters); + else + snprintf(distStr, sizeof(distStr), "1k"); + } else { + int km = (int)(distanceKm + 0.5); + if (km < 1000) + snprintf(distStr, sizeof(distStr), "%dk", km); + else + snprintf(distStr, sizeof(distStr), "999"); + } + } + } + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nameMaxWidth, nodeName); + if (node->is_favorite) { + if (SCREEN_WIDTH > 128) { + drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); + } else { + display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); + } + } + + if (strlen(distStr) > 0) { + int offset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column) + : (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column) + int rightEdge = x + columnWidth - offset; + int textWidth = display->getStringWidth(distStr); + display->drawString(rightEdge - textWidth, y, distStr); + } +} + +void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + switch (currentMode) { + case MODE_LAST_HEARD: + drawEntryLastHeard(display, node, x, y, columnWidth); + break; + case MODE_HOP_SIGNAL: + drawEntryHopSignal(display, node, x, y, columnWidth); + break; + case MODE_DISTANCE: + drawNodeDistance(display, node, x, y, columnWidth); + break; + default: + break; + } +} + +void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + bool isLeftCol = (x < SCREEN_WIDTH / 2); + + // Adjust max text width depending on column and screen width + int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); + + const char *nodeName = getSafeNodeName(node); + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), y, nameMaxWidth, nodeName); + if (node->is_favorite) { + if (SCREEN_WIDTH > 128) { + drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); + } else { + display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); + } + } +} + +void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading, + double userLat, double userLon) +{ + if (!nodeDB->hasValidPosition(node)) + return; + + bool isLeftCol = (x < SCREEN_WIDTH / 2); + int arrowXOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18); + + int centerX = x + columnWidth - arrowXOffset; + int centerY = y + FONT_HEIGHT_SMALL / 2; + + double nodeLat = node->position.latitude_i * 1e-7; + double nodeLon = node->position.longitude_i * 1e-7; + float bearingToNode = calculateBearing(userLat, userLon, nodeLat, nodeLon); + float relativeBearing = fmod((bearingToNode - myHeading + 360), 360); + float angle = relativeBearing * DEG_TO_RAD; + + // Shrink size by 2px + int size = FONT_HEIGHT_SMALL - 5; + float halfSize = size / 2.0; + + // Point of the arrow + int tipX = centerX + halfSize * cos(angle); + int tipY = centerY - halfSize * sin(angle); + + float baseAngle = radians(35); + float sideLen = halfSize * 0.95; + float notchInset = halfSize * 0.35; + + // Left and right corners + int leftX = centerX + sideLen * cos(angle + PI - baseAngle); + int leftY = centerY - sideLen * sin(angle + PI - baseAngle); + + int rightX = centerX + sideLen * cos(angle + PI + baseAngle); + int rightY = centerY - sideLen * sin(angle + PI + baseAngle); + + // Center notch (cut-in) + int notchX = centerX - notchInset * cos(angle); + int notchY = centerY + notchInset * sin(angle); + + // Draw the chevron-style arrowhead + display->fillTriangle(tipX, tipY, leftX, leftY, notchX, notchY); + display->fillTriangle(tipX, tipY, notchX, notchY, rightX, rightY); +} + +// ============================= +// Main Screen Functions +// ============================= + +void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, + EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon) +{ + const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1; + const int rowYOffset = FONT_HEIGHT_SMALL - 3; + + int columnWidth = display->getWidth() / 2; + + display->clear(); + + // Draw the battery/time header + graphics::drawCommonHeader(display, x, y, title); + + // Space below header + y += COMMON_HEADER_HEIGHT; + + // Fetch and display sorted node list + std::vector nodeList; + retrieveAndSortNodes(nodeList); + + int totalEntries = nodeList.size(); + int totalRowsAvailable = (display->getHeight() - y) / rowYOffset; +#ifdef USE_EINK + totalRowsAvailable -= 1; +#endif + int visibleNodeRows = totalRowsAvailable; + int totalColumns = 2; + + int startIndex = scrollIndex * visibleNodeRows * totalColumns; + int endIndex = std::min(startIndex + visibleNodeRows * totalColumns, totalEntries); + + int yOffset = 0; + int col = 0; + int lastNodeY = y; + int shownCount = 0; + int rowCount = 0; + + for (int i = startIndex; i < endIndex; ++i) { + int xPos = x + (col * columnWidth); + int yPos = y + yOffset; + renderer(display, nodeList[i].node, xPos, yPos, columnWidth); + + if (extras) { + extras(display, nodeList[i].node, xPos, yPos, columnWidth, heading, lat, lon); + } + + lastNodeY = std::max(lastNodeY, yPos + FONT_HEIGHT_SMALL); + yOffset += rowYOffset; + shownCount++; + rowCount++; + + if (rowCount >= totalRowsAvailable) { + yOffset = 0; + rowCount = 0; + col++; + if (col > (totalColumns - 1)) + break; + } + } + + // Draw column separator + if (shownCount > 0) { + const int firstNodeY = y + 3; + drawColumnSeparator(display, x, firstNodeY, lastNodeY); + } + + const int scrollStartY = y + 3; + drawScrollbar(display, visibleNodeRows, totalEntries, scrollIndex, 2, scrollStartY); +} + +// ============================= +// Screen Frame Functions +// ============================= + +#ifndef USE_EINK +void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + // Static variables to track mode and duration + static NodeListMode lastRenderedMode = MODE_COUNT; + static unsigned long modeStartTime = 0; + + unsigned long now = millis(); + + // On very first call (on boot or state enter) + if (lastRenderedMode == MODE_COUNT) { + currentMode = MODE_LAST_HEARD; + modeStartTime = now; + } + + // Time to switch to next mode? + if (now - modeStartTime >= getModeCycleIntervalMs()) { + currentMode = static_cast((currentMode + 1) % MODE_COUNT); + modeStartTime = now; + } + + // Render screen based on currentMode + const char *title = getCurrentModeTitle(display->getWidth()); + drawNodeListScreen(display, state, x, y, title, drawEntryDynamic); + + // Track the last mode to avoid reinitializing modeStartTime + lastRenderedMode = currentMode; +} +#endif + +#ifdef USE_EINK +void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + const char *title = "Last Heard"; + drawNodeListScreen(display, state, x, y, title, drawEntryLastHeard); +} + +void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + const char *title = "Hops/Signal"; + drawNodeListScreen(display, state, x, y, title, drawEntryHopSignal); +} + +void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + const char *title = "Distance"; + drawNodeListScreen(display, state, x, y, title, drawNodeDistance); +} +#endif + +void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + float heading = 0; + bool validHeading = false; + double lat = 0; + double lon = 0; + +#if HAS_GPS + if (screen->hasHeading()) { + heading = screen->getHeading(); // degrees + validHeading = true; + } else { + heading = screen->estimatedHeading(lat, lon); + validHeading = !isnan(heading); + } +#endif + + if (!validHeading) + return; + + drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon); +} + +/// Draw a series of fields in a column, wrapping to multiple columns if needed +void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields) +{ + // The coordinates define the left starting point of the text + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const char **f = fields; + int xo = x, yo = y; + while (*f) { + display->drawString(xo, yo, *f); + if ((display->getColor() == BLACK) && config.display.heading_bold) + display->drawString(xo + 1, yo, *f); + + display->setColor(WHITE); + yo += FONT_HEIGHT_SMALL; + if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) { + xo += SCREEN_WIDTH / 2; + yo = 0; + } + f++; + } +} + +} // namespace NodeListRenderer +} // namespace graphics +#endif \ No newline at end of file diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h new file mode 100644 index 000000000..63f0d1c69 --- /dev/null +++ b/src/graphics/draw/NodeListRenderer.h @@ -0,0 +1,69 @@ +#pragma once + +#include "graphics/Screen.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +namespace graphics +{ + +/// Forward declarations +class Screen; + +/** + * @brief Node list and entry rendering functions + * + * Contains all functions related to drawing node lists and individual node entries + * including last heard, hop signal, distance, and compass views. + */ +namespace NodeListRenderer +{ +// Entry renderer function types +typedef void (*EntryRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int); +typedef void (*NodeExtrasRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int, float, double, double); + +// Node entry structure +struct NodeEntry { + meshtastic_NodeInfoLite *node; + uint32_t sortValue; +}; + +// Node list mode enumeration +enum NodeListMode { MODE_LAST_HEARD = 0, MODE_HOP_SIGNAL = 1, MODE_DISTANCE = 2, MODE_COUNT = 3 }; + +// Main node list screen function +void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, + EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0, + double lon = 0); + +// Entry renderers +void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); +void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); +void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); +void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); +void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); + +// Extras renderers +void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading, + double userLat, double userLon); + +// Screen frame functions +void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// Utility functions +const char *getCurrentModeTitle(int screenWidth); +void retrieveAndSortNodes(std::vector &nodeList); +const char *getSafeNodeName(meshtastic_NodeInfoLite *node); +void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields); + +// Bitmap drawing function +void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display); + +} // namespace NodeListRenderer + +} // namespace graphics diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp new file mode 100644 index 000000000..ed5257012 --- /dev/null +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -0,0 +1,265 @@ +#include "configuration.h" +#if HAS_SCREEN + +#include "DisplayFormatters.h" +#include "NodeDB.h" +#include "NotificationRenderer.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" +#include "main.h" +#include +#include +#include + +#ifdef ARCH_ESP32 +#include "esp_task_wdt.h" +#endif + +using namespace meshtastic; + +// External references to global variables from Screen.cpp +extern std::vector functionSymbol; +extern std::string functionSymbolString; +extern bool hasUnreadMessage; + +namespace graphics +{ + +char NotificationRenderer::inEvent = INPUT_BROKER_NONE; +int8_t NotificationRenderer::curSelected = 0; +char NotificationRenderer::alertBannerMessage[256] = {0}; +uint32_t NotificationRenderer::alertBannerUntil = 0; // 0 is a special case meaning forever +uint8_t NotificationRenderer::alertBannerOptions = 0; // last x lines are seelctable options +std::function NotificationRenderer::alertBannerCallback = NULL; +bool NotificationRenderer::pauseBanner = false; + +// Used on boot when a certificate is being created +void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_SMALL); + display->drawString(64 + x, y, "Creating SSL certificate"); + +#ifdef ARCH_ESP32 + yield(); + esp_task_wdt_reset(); +#endif + + display->setFont(FONT_SMALL); + if ((millis() / 1000) % 2) { + display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ."); + } else { + display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . "); + } +} + +void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + // Exit if no message is active or duration has passed + if (!isOverlayBannerShowing()) + return; + + if (pauseBanner) + return; + + // === Layout Configuration === + constexpr uint16_t padding = 5; // Padding around text inside the box + constexpr uint16_t vPadding = 2; // Padding around text inside the box + constexpr uint8_t lineSpacing = 1; // Extra space between lines + + // Search the message to determine if we need the bell added + bool needs_bell = (strstr(alertBannerMessage, "Alert Received") != nullptr); + + uint8_t firstOption = 0; + uint8_t firstOptionToShow = 0; + + // Setup font and alignment + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); // We will manually center per line + const int MAX_LINES = 24; + + uint16_t maxWidth = 0; + uint16_t arrowsWidth = display->getStringWidth("> <", 4, true); + uint16_t lineWidths[MAX_LINES] = {0}; + uint16_t lineLengths[MAX_LINES] = {0}; + char *lineStarts[MAX_LINES + 1]; + uint16_t lineCount = 0; + char lineBuffer[40] = {0}; + // pointer to the terminating null + char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage)); + lineStarts[lineCount] = alertBannerMessage; + + // loop through lines finding \n characters + while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { + lineStarts[lineCount + 1] = std::find(lineStarts[lineCount], alertEnd, '\n'); + lineLengths[lineCount] = lineStarts[lineCount + 1] - lineStarts[lineCount]; + if (lineStarts[lineCount + 1][0] == '\n') { + lineStarts[lineCount + 1] += 1; // Move the start pointer beyond the \n + } + lineWidths[lineCount] = display->getStringWidth(lineStarts[lineCount], lineLengths[lineCount], true); + if (lineWidths[lineCount] > maxWidth) { + maxWidth = lineWidths[lineCount]; + } + if (alertBannerOptions > 0 && lineCount > 0 && lineWidths[lineCount] + arrowsWidth > maxWidth) { + maxWidth = lineWidths[lineCount] + arrowsWidth; + } + lineCount++; + // if we are doing a selection, add extra width for arrows + } + + if (alertBannerOptions > 0) { + // respond to input + if (inEvent == INPUT_BROKER_UP || inEvent == INPUT_BROKER_ALT_PRESS) { + curSelected--; + } else if (inEvent == INPUT_BROKER_DOWN || inEvent == INPUT_BROKER_USER_PRESS) { + curSelected++; + } else if (inEvent == INPUT_BROKER_SELECT) { + alertBannerCallback(curSelected); + alertBannerMessage[0] = '\0'; + } else if ((inEvent == INPUT_BROKER_CANCEL || inEvent == INPUT_BROKER_ALT_LONG) && alertBannerUntil != 0) { + alertBannerMessage[0] = '\0'; + } + if (curSelected == -1) + curSelected = alertBannerOptions - 1; + if (curSelected == alertBannerOptions) + curSelected = 0; + // compare number of options to number of lines + if (lineCount < alertBannerOptions) + return; + firstOption = lineCount - alertBannerOptions; + if (curSelected > 1 && alertBannerOptions > 3) { + firstOptionToShow = curSelected + firstOption - 1; + // put the selected option in the middle + } else { + firstOptionToShow = firstOption; + } + } else { // not in an alert with a callback + // TODO: check that at least a second has passed since the alert started + if (inEvent == INPUT_BROKER_SELECT || inEvent == INPUT_BROKER_ALT_LONG || inEvent == INPUT_BROKER_CANCEL) { + alertBannerMessage[0] = '\0'; // end the alert early + } + } + inEvent = INPUT_BROKER_NONE; + if (alertBannerMessage[0] == '\0') + return; + + // set width from longest line + uint16_t boxWidth = padding * 2 + maxWidth; + if (needs_bell) { + if (SCREEN_WIDTH > 128 && boxWidth <= 150) { + boxWidth += 26; + } + if (SCREEN_WIDTH <= 128 && boxWidth <= 100) { + boxWidth += 20; + } + } + // calculate max lines on screen? for now it's 4 + // set height from line count + uint16_t boxHeight; + if (lineCount <= 4) { + boxHeight = vPadding * 2 + lineCount * FONT_HEIGHT_SMALL + (lineCount - 1) * lineSpacing; + } else { + boxHeight = vPadding * 2 + 4 * FONT_HEIGHT_SMALL + 4 * lineSpacing; + } + + int16_t boxLeft = (display->width() / 2) - (boxWidth / 2); + int16_t boxTop = (display->height() / 2) - (boxHeight / 2); + // === Draw background box === + display->setColor(BLACK); + display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Slightly oversized box + display->fillRect(boxLeft, boxTop - 2, boxWidth, 1); // Top Line + display->fillRect(boxLeft, boxTop + boxHeight + 1, boxWidth, 1); // Bottom Line + display->fillRect(boxLeft - 2, boxTop, 1, boxHeight); // Left Line + display->fillRect(boxLeft + boxWidth + 1, boxTop, 1, boxHeight); // Right Line + display->setColor(WHITE); + display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); // Border + display->setColor(BLACK); + display->fillRect(boxLeft, boxTop, 1, 1); // Top Left + display->fillRect(boxLeft + boxWidth - 1, boxTop, 1, 1); // Top Right + display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1); // Bottom Left + display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1); // Bottom Right + display->setColor(WHITE); + + // === Draw each line centered in the box === + int16_t lineY = boxTop + vPadding; + + for (int i = 0; i < lineCount; i++) { + // is this line selected? + // if so, start the buffer with -> and strncpy to the 4th location + if (i < lineCount - alertBannerOptions || alertBannerOptions == 0) { + strncpy(lineBuffer, lineStarts[i], 40); + if (lineLengths[i] > 39) + lineBuffer[39] = '\0'; + else + lineBuffer[lineLengths[i]] = '\0'; + } else if (i >= firstOptionToShow && i < firstOptionToShow + 3) { + if (i == curSelected + firstOption) { + if (lineLengths[i] > 35) + lineLengths[i] = 35; + strncpy(lineBuffer, "> ", 3); + strncpy(lineBuffer + 2, lineStarts[i], 36); + strncpy(lineBuffer + lineLengths[i] + 2, " <", 3); + lineLengths[i] += 4; + lineWidths[i] += display->getStringWidth("> <", 4, true); + if (lineLengths[i] > 35) + lineBuffer[39] = '\0'; + else + lineBuffer[lineLengths[i]] = '\0'; + } else { + strncpy(lineBuffer, lineStarts[i], 40); + if (lineLengths[i] > 39) + lineBuffer[39] = '\0'; + else + lineBuffer[lineLengths[i]] = '\0'; + } + } else { // add break for the additional lines + continue; + } + + int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2; + + if (needs_bell && i == 0) { + int bellY = lineY + (FONT_HEIGHT_SMALL - 8) / 2; + display->drawXbm(textX - 10, bellY, 8, 8, bell_alert); + display->drawXbm(textX + lineWidths[i] + 2, bellY, 8, 8, bell_alert); + } + + display->drawString(textX, lineY, lineBuffer); + lineY += FONT_HEIGHT_SMALL + lineSpacing; + } +} + +/// Draw the last text message we received +void NotificationRenderer::drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_MEDIUM); + + char tempBuf[24]; + snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code); + display->drawString(0 + x, 0 + y, tempBuf); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org"); +} + +void NotificationRenderer::drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_MEDIUM); + display->drawString(64 + x, y, "Updating"); + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(), + "Please be patient and do not power off."); +} + +bool NotificationRenderer::isOverlayBannerShowing() +{ + return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil); +} + +} // namespace graphics +#endif \ No newline at end of file diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h new file mode 100644 index 000000000..3ed931dc6 --- /dev/null +++ b/src/graphics/draw/NotificationRenderer.h @@ -0,0 +1,28 @@ +#pragma once + +#include "OLEDDisplay.h" +#include "OLEDDisplayUi.h" + +namespace graphics +{ + +class NotificationRenderer +{ + public: + static char inEvent; + static int8_t curSelected; + static char alertBannerMessage[256]; + static uint32_t alertBannerUntil; // 0 is a special case meaning forever + static uint8_t alertBannerOptions; // last x lines are seelctable options + static std::function alertBannerCallback; + + static bool pauseBanner; + + static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); + static void drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static bool isOverlayBannerShowing(); +}; + +} // namespace graphics diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp new file mode 100644 index 000000000..a77d5b44b --- /dev/null +++ b/src/graphics/draw/UIRenderer.cpp @@ -0,0 +1,1240 @@ +#include "configuration.h" +#if HAS_SCREEN +#include "CompassRenderer.h" +#include "GPSStatus.h" +#include "NodeDB.h" +#include "NodeListRenderer.h" +#include "UIRenderer.h" +#include "airtime.h" +#include "configuration.h" +#include "gps/GeoCoord.h" +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" +#include "main.h" +#include "target_specific.h" +#include +#include +#include + +#if !MESHTASTIC_EXCLUDE_GPS + +// External variables +extern graphics::Screen *screen; + +namespace graphics +{ + +// GeoCoord object for coordinate conversions +extern GeoCoord geoCoord; + +// Threshold values for the GPS lock accuracy bar display +extern uint32_t dopThresholds[5]; + +NodeNum UIRenderer::currentFavoriteNodeNum = 0; + +// Draw GPS status summary +void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps) +{ + // Draw satellite image + if (SCREEN_WIDTH > 128) { + NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgSatellite_width, imgSatellite_height, imgSatellite, display); + } else { + display->drawXbm(x + 1, y + 1, imgSatellite_width, imgSatellite_height, imgSatellite); + } + char textString[10]; + + if (config.position.fixed_position) { + // GPS coordinates are currently fixed + snprintf(textString, sizeof(textString), "Fixed"); + } + if (!gps->getIsConnected()) { + snprintf(textString, sizeof(textString), "No Lock"); + } + if (!gps->getHasLock()) { + // Draw "No sats" to the right of the icon with slightly more gap + snprintf(textString, sizeof(textString), "No Sats"); + } else { + snprintf(textString, sizeof(textString), "%u sats", gps->getNumSatellites()); + } + if (SCREEN_WIDTH > 128) { + display->drawString(x + 18, y, textString); + } else { + display->drawString(x + 11, y, textString); + } +} + +// Draw status when GPS is disabled or not present +void UIRenderer::drawGpsPowerStatus(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps) +{ + const char *displayLine; + int pos; + if (y < FONT_HEIGHT_SMALL) { // Line 1: use short string + displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; + pos = display->getWidth() - display->getStringWidth(displayLine); + } else { + displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "GPS not present" + : "GPS is disabled"; + pos = (display->getWidth() - display->getStringWidth(displayLine)) / 2; + } + display->drawString(x + pos, y, displayLine); +} + +void UIRenderer::drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps) +{ + char displayLine[32]; + if (!gps->getIsConnected() && !config.position.fixed_position) { + // displayLine = "No GPS Module"; + // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } else if (!gps->getHasLock() && !config.position.fixed_position) { + // displayLine = "No GPS Lock"; + // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } else { + geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) + snprintf(displayLine, sizeof(displayLine), "Altitude: %.0fft", geoCoord.getAltitude() * METERS_TO_FEET); + else + snprintf(displayLine, sizeof(displayLine), "Altitude: %.0im", geoCoord.getAltitude()); + display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } +} + +// Draw GPS status coordinates +void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps) +{ + auto gpsFormat = config.display.gps_format; + char displayLine[32]; + + if (!gps->getIsConnected() && !config.position.fixed_position) { + strcpy(displayLine, "No GPS present"); + display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } else if (!gps->getHasLock() && !config.position.fixed_position) { + strcpy(displayLine, "No GPS Lock"); + display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } else { + + geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); + + if (gpsFormat != meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) { + char coordinateLine[22]; + if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DEC) { // Decimal Degrees + snprintf(coordinateLine, sizeof(coordinateLine), "%f %f", geoCoord.getLatitude() * 1e-7, + geoCoord.getLongitude() * 1e-7); + } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_UTM) { // Universal Transverse Mercator + snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %06u %07u", geoCoord.getUTMZone(), geoCoord.getUTMBand(), + geoCoord.getUTMEasting(), geoCoord.getUTMNorthing()); + } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_MGRS) { // Military Grid Reference System + snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %1c%1c %05u %05u", geoCoord.getMGRSZone(), + geoCoord.getMGRSBand(), geoCoord.getMGRSEast100k(), geoCoord.getMGRSNorth100k(), + geoCoord.getMGRSEasting(), geoCoord.getMGRSNorthing()); + } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OLC) { // Open Location Code + geoCoord.getOLCCode(coordinateLine); + } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OSGR) { // Ordnance Survey Grid Reference + if (geoCoord.getOSGRE100k() == 'I' || geoCoord.getOSGRN100k() == 'I') // OSGR is only valid around the UK region + snprintf(coordinateLine, sizeof(coordinateLine), "%s", "Out of Boundary"); + else + snprintf(coordinateLine, sizeof(coordinateLine), "%1c%1c %05u %05u", geoCoord.getOSGRE100k(), + geoCoord.getOSGRN100k(), geoCoord.getOSGREasting(), geoCoord.getOSGRNorthing()); + } + + // If fixed position, display text "Fixed GPS" alternating with the coordinates. + if (config.position.fixed_position) { + if ((millis() / 10000) % 2) { + display->drawString(x + (display->getWidth() - (display->getStringWidth(coordinateLine))) / 2, y, + coordinateLine); + } else { + display->drawString(x + (display->getWidth() - (display->getStringWidth("Fixed GPS"))) / 2, y, "Fixed GPS"); + } + } else { + display->drawString(x + (display->getWidth() - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine); + } + } else { + char latLine[22]; + char lonLine[22]; + snprintf(latLine, sizeof(latLine), "%2i° %2i' %2u\" %1c", geoCoord.getDMSLatDeg(), geoCoord.getDMSLatMin(), + geoCoord.getDMSLatSec(), geoCoord.getDMSLatCP()); + snprintf(lonLine, sizeof(lonLine), "%3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(), geoCoord.getDMSLonMin(), + geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP()); + display->drawString(x + (display->getWidth() - (display->getStringWidth(latLine))) / 2, y - FONT_HEIGHT_SMALL * 1, + latLine); + display->drawString(x + (display->getWidth() - (display->getStringWidth(lonLine))) / 2, y, lonLine); + } + } +} + +void UIRenderer::drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer, + const meshtastic::PowerStatus *powerStatus) +{ + static const uint8_t powerBar[3] = {0x81, 0xBD, 0xBD}; + static const uint8_t lightning[8] = {0xA1, 0xA1, 0xA5, 0xAD, 0xB5, 0xA5, 0x85, 0x85}; + + // Clear the bar area inside the battery image + for (int i = 1; i < 14; i++) { + imgBuffer[i] = 0x81; + } + + // Fill with lightning or power bars + if (powerStatus->getIsCharging()) { + memcpy(imgBuffer + 3, lightning, 8); + } else { + for (int i = 0; i < 4; i++) { + if (powerStatus->getBatteryChargePercent() >= 25 * i) + memcpy(imgBuffer + 1 + (i * 3), powerBar, 3); + } + } + + // Slightly more conservative scaling based on screen width + int scale = 1; + + if (SCREEN_WIDTH >= 200) + scale = 2; + if (SCREEN_WIDTH >= 300) + scale = 2; // Do NOT go higher than 2 + + // Draw scaled battery image (16 columns × 8 rows) + for (int col = 0; col < 16; col++) { + uint8_t colBits = imgBuffer[col]; + for (int row = 0; row < 8; row++) { + if (colBits & (1 << row)) { + display->fillRect(x + col * scale, y + row * scale, scale, scale); + } + } + } +} + +// Draw nodes status +void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus, int node_offset, + bool show_total, String additional_words) +{ + char usersString[20]; + int nodes_online = (nodeStatus->getNumOnline() > 0) ? nodeStatus->getNumOnline() + node_offset : 0; + + snprintf(usersString, sizeof(usersString), "%d %s", nodes_online, additional_words.c_str()); + + if (show_total) { + int nodes_total = (nodeStatus->getNumTotal() > 0) ? nodeStatus->getNumTotal() + node_offset : 0; + snprintf(usersString, sizeof(usersString), "%d/%d %s", nodes_online, nodes_total, additional_words.c_str()); + } + +#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ + defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS)) && \ + !defined(DISPLAY_FORCE_SMALL_FONTS) + + if (SCREEN_WIDTH > 128) { + NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display); + } else { + display->drawFastImage(x, y + 3, 8, 8, imgUser); + } +#else + if (SCREEN_WIDTH > 128) { + NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display); + } else { + display->drawFastImage(x, y + 1, 8, 8, imgUser); + } +#endif + int string_offset = (SCREEN_WIDTH > 128) ? 9 : 0; + display->drawString(x + 10 + string_offset, y - 2, usersString); +} + +// ********************** +// * Favorite Node Info * +// ********************** +void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + // --- Cache favorite nodes for the current frame only, to save computation --- + static std::vector favoritedNodes; + static int prevFrame = -1; + + // --- Only rebuild favorites list if we're on a new frame --- + if (state->currentFrame != prevFrame) { + prevFrame = state->currentFrame; + favoritedNodes.clear(); + size_t total = nodeDB->getNumMeshNodes(); + for (size_t i = 0; i < total; i++) { + meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); + // Skip nulls and ourself + if (!n || n->num == nodeDB->getNodeNum()) + continue; + if (n->is_favorite) + favoritedNodes.push_back(n); + } + // Keep a stable, consistent display order + std::sort(favoritedNodes.begin(), favoritedNodes.end(), + [](const meshtastic_NodeInfoLite *a, const meshtastic_NodeInfoLite *b) { return a->num < b->num; }); + } + if (favoritedNodes.empty()) + return; + + // --- Only display if index is valid --- + int nodeIndex = state->currentFrame - (screen->frameCount - favoritedNodes.size()); + if (nodeIndex < 0 || nodeIndex >= (int)favoritedNodes.size()) + return; + + meshtastic_NodeInfoLite *node = favoritedNodes[nodeIndex]; + if (!node || node->num == nodeDB->getNodeNum() || !node->is_favorite) + return; + + display->clear(); + currentFavoriteNodeNum = node->num; + // === Create the shortName and title string === + const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node"; + char titlestr[32] = {0}; + snprintf(titlestr, sizeof(titlestr), "Fav: %s", shortName); + + // === Draw battery/time/mail header (common across screens) === + graphics::drawCommonHeader(display, x, y, titlestr); + + // ===== DYNAMIC ROW STACKING WITH YOUR MACROS ===== + // 1. Each potential info row has a macro-defined Y position (not regular increments!). + // 2. Each row is only shown if it has valid data. + // 3. Each row "moves up" if previous are empty, so there are never any blank rows. + // 4. The first line is ALWAYS at your macro position; subsequent lines use the next available macro slot. + + // List of available macro Y positions in order, from top to bottom. + int line = 1; // which slot to use next + + // === 1. Long Name (always try to show first) === + const char *username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr; + if (username && line < 5) { + // Print node's long name (e.g. "Backpack Node") + display->drawString(x, getTextPositions(display)[line++], username); + } + + // === 2. Signal and Hops (combined on one line, if available) === + // If both are present: "Sig: 97% [2hops]" + // If only one: show only that one + char signalHopsStr[32] = ""; + bool haveSignal = false; + int percentSignal = clamp((int)((node->snr + 10) * 5), 0, 100); + + // Always use "Sig" for the label + const char *signalLabel = " Sig"; + + // --- Build the Signal/Hops line --- + // If SNR looks reasonable, show signal + if ((int)((node->snr + 10) * 5) >= 0 && node->snr > -100) { + snprintf(signalHopsStr, sizeof(signalHopsStr), "%s: %d%%", signalLabel, percentSignal); + haveSignal = true; + } + // If hops is valid (>0), show right after signal + if (node->hops_away > 0) { + size_t len = strlen(signalHopsStr); + // Decide between "1 Hop" and "N Hops" + if (haveSignal) { + snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [%d %s]", node->hops_away, + (node->hops_away == 1 ? "Hop" : "Hops")); + } else { + snprintf(signalHopsStr, sizeof(signalHopsStr), "[%d %s]", node->hops_away, (node->hops_away == 1 ? "Hop" : "Hops")); + } + } + if (signalHopsStr[0] && line < 5) { + display->drawString(x, getTextPositions(display)[line++], signalHopsStr); + } + + // === 3. Heard (last seen, skip if node never seen) === + char seenStr[20] = ""; + uint32_t seconds = sinceLastSeen(node); + if (seconds != 0 && seconds != UINT32_MAX) { + uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24; + // Format as "Heard: Xm ago", "Heard: Xh ago", or "Heard: Xd ago" + snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard: ?" : " Heard: %d%c ago"), + (days ? days + : hours ? hours + : minutes), + (days ? 'd' + : hours ? 'h' + : 'm')); + } + if (seenStr[0] && line < 5) { + display->drawString(x, getTextPositions(display)[line++], seenStr); + } + + // === 4. Uptime (only show if metric is present) === + char uptimeStr[32] = ""; + if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) { + uint32_t uptime = node->device_metrics.uptime_seconds; + uint32_t days = uptime / 86400; + uint32_t hours = (uptime % 86400) / 3600; + uint32_t mins = (uptime % 3600) / 60; + // Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m" + if (days) + snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %ud %uh", days, hours); + else if (hours) + snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %uh %um", hours, mins); + else + snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %um", mins); + } + if (uptimeStr[0] && line < 5) { + display->drawString(x, getTextPositions(display)[line++], uptimeStr); + } + + // === 5. Distance (only if both nodes have GPS position) === + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + char distStr[24] = ""; // Make buffer big enough for any string + bool haveDistance = false; + + if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) { + double lat1 = ourNode->position.latitude_i * 1e-7; + double lon1 = ourNode->position.longitude_i * 1e-7; + double lat2 = node->position.latitude_i * 1e-7; + double lon2 = node->position.longitude_i * 1e-7; + double earthRadiusKm = 6371.0; + double dLat = (lat2 - lat1) * DEG_TO_RAD; + double dLon = (lon2 - lon1) * DEG_TO_RAD; + double a = + sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2); + double c = 2 * atan2(sqrt(a), sqrt(1 - a)); + double distanceKm = earthRadiusKm * c; + + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { + double miles = distanceKm * 0.621371; + if (miles < 0.1) { + int feet = (int)(miles * 5280); + if (feet > 0 && feet < 1000) { + snprintf(distStr, sizeof(distStr), " Distance: %dft", feet); + haveDistance = true; + } else if (feet >= 1000) { + snprintf(distStr, sizeof(distStr), " Distance: ¼mi"); + haveDistance = true; + } + } else { + int roundedMiles = (int)(miles + 0.5); + if (roundedMiles > 0 && roundedMiles < 1000) { + snprintf(distStr, sizeof(distStr), " Distance: %dmi", roundedMiles); + haveDistance = true; + } + } + } else { + if (distanceKm < 1.0) { + int meters = (int)(distanceKm * 1000); + if (meters > 0 && meters < 1000) { + snprintf(distStr, sizeof(distStr), " Distance: %dm", meters); + haveDistance = true; + } else if (meters >= 1000) { + snprintf(distStr, sizeof(distStr), " Distance: 1km"); + haveDistance = true; + } + } else { + int km = (int)(distanceKm + 0.5); + if (km > 0 && km < 1000) { + snprintf(distStr, sizeof(distStr), " Distance: %dkm", km); + haveDistance = true; + } + } + } + } + // Only display if we actually have a value! + if (haveDistance && distStr[0] && line < 5) { + display->drawString(x, getTextPositions(display)[line++], distStr); + } + + // --- Compass Rendering: landscape (wide) screens use the original side-aligned logic --- + if (SCREEN_WIDTH > SCREEN_HEIGHT) { + bool showCompass = false; + if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) { + showCompass = true; + } + if (showCompass) { + const int16_t topY = getTextPositions(display)[1]; + const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); + const int16_t usableHeight = bottomY - topY - 5; + int16_t compassRadius = usableHeight / 2; + if (compassRadius < 8) + compassRadius = 8; + const int16_t compassDiam = compassRadius * 2; + const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8; + const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; + + const auto &op = ourNode->position; + float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180 + : screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i)); + + const auto &p = node->position; + /* unused + float d = + GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); + */ + float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i)); + if (!config.display.compass_north_top) + bearing -= myHeading; + + display->drawCircle(compassX, compassY, compassRadius); + CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing); + } + // else show nothing + } else { + // Portrait or square: put compass at the bottom and centered, scaled to fit available space + bool showCompass = false; + if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) { + showCompass = true; + } + if (showCompass) { + int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2) + : getTextPositions(display)[1]; + const int margin = 4; +// --------- PATCH FOR EINK NAV BAR (ONLY CHANGE BELOW) ----------- +#if defined(USE_EINK) + const int iconSize = (SCREEN_WIDTH > 128) ? 16 : 8; + const int navBarHeight = iconSize + 6; +#else + const int navBarHeight = 0; +#endif + int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin; + // --------- END PATCH FOR EINK NAV BAR ----------- + + if (availableHeight < FONT_HEIGHT_SMALL * 2) + return; + + int compassRadius = availableHeight / 2; + if (compassRadius < 8) + compassRadius = 8; + if (compassRadius * 2 > SCREEN_WIDTH - 16) + compassRadius = (SCREEN_WIDTH - 16) / 2; + + int compassX = x + SCREEN_WIDTH / 2; + int compassY = yBelowContent + availableHeight / 2; + + const auto &op = ourNode->position; + float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180 + : screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i)); + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + + const auto &p = node->position; + /* unused + float d = + GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); + */ + float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i)); + if (!config.display.compass_north_top) + bearing -= myHeading; + graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing); + + display->drawCircle(compassX, compassY, compassRadius); + } + // else show nothing + } +} + +// **************************** +// * Device Focused Screen * +// **************************** +void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + int line = 1; + + // === Header === + graphics::drawCommonHeader(display, x, y, ""); + + // === Content below header === + + // Determine if we need to show 4 or 5 rows on the screen + int rows = 4; + if (!config.bluetooth.enabled) { + rows = 5; + } + + // === First Row: Region / Channel Utilization and Uptime === + bool origBold = config.display.heading_bold; + config.display.heading_bold = false; + + // Display Region and Channel Utilization + drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online"); + + char uptimeStr[32] = ""; + uint32_t uptime = millis() / 1000; + uint32_t days = uptime / 86400; + uint32_t hours = (uptime % 86400) / 3600; + uint32_t mins = (uptime % 3600) / 60; + // Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m" + if (days) + snprintf(uptimeStr, sizeof(uptimeStr), "Up: %ud %uh", days, hours); + else if (hours) + snprintf(uptimeStr, sizeof(uptimeStr), "Up: %uh %um", hours, mins); + else + snprintf(uptimeStr, sizeof(uptimeStr), "Up: %um", mins); + display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr); + + // === Second Row: Satellites and Voltage === + config.display.heading_bold = false; + +#if HAS_GPS + if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + const char *displayLine; + if (config.position.fixed_position) { + displayLine = "Fixed GPS"; + } else { + displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; + } + int yOffset = (SCREEN_WIDTH > 128) ? 3 : 1; + if (SCREEN_WIDTH > 128) { + NodeListRenderer::drawScaledXBitmap16x16(x, getTextPositions(display)[line] + yOffset - 5, imgSatellite_width, + imgSatellite_height, imgSatellite, display); + } else { + display->drawXbm(x + 1, getTextPositions(display)[line] + yOffset, imgSatellite_width, imgSatellite_height, + imgSatellite); + } + int xOffset = (SCREEN_WIDTH > 128) ? 6 : 0; + display->drawString(x + 11 + xOffset, getTextPositions(display)[line], displayLine); + } else { + UIRenderer::drawGps(display, 0, getTextPositions(display)[line], gpsStatus); + } +#endif + + if (powerStatus->getHasBattery()) { + char batStr[20]; + int batV = powerStatus->getBatteryVoltageMv() / 1000; + int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; + snprintf(batStr, sizeof(batStr), "%01d.%02dV", batV, batCv); + display->drawString(x + SCREEN_WIDTH - display->getStringWidth(batStr), getTextPositions(display)[line++], batStr); + } else { + display->drawString(x + SCREEN_WIDTH - display->getStringWidth("USB"), getTextPositions(display)[line++], "USB"); + } + + config.display.heading_bold = origBold; + + // === Third Row: Channel Utilization Bluetooth Off (Only If Actually Off) === + const char *chUtil = "ChUtil:"; + char chUtilPercentage[10]; + snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent()); + + int chUtil_x = (SCREEN_WIDTH > 128) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5; + int chUtil_y = getTextPositions(display)[line] + 3; + + int chutil_bar_width = (SCREEN_WIDTH > 128) ? 100 : 50; + if (!config.bluetooth.enabled) { + chutil_bar_width = (SCREEN_WIDTH > 128) ? 80 : 40; + } + int chutil_bar_height = (SCREEN_WIDTH > 128) ? 12 : 7; + int extraoffset = (SCREEN_WIDTH > 128) ? 6 : 3; + if (!config.bluetooth.enabled) { + extraoffset = (SCREEN_WIDTH > 128) ? 6 : 1; + } + int chutil_percent = airTime->channelUtilizationPercent(); + + int centerofscreen = SCREEN_WIDTH / 2; + int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; + int starting_position = centerofscreen - total_line_content_width; + if (!config.bluetooth.enabled) { + starting_position = 0; + } + + display->drawString(starting_position, getTextPositions(display)[line], chUtil); + + // Force 56% or higher to show a full 100% bar, text would still show related percent. + if (chutil_percent >= 61) { + chutil_percent = 100; + } + + // Weighting for nonlinear segments + float milestone1 = 25; + float milestone2 = 40; + float weight1 = 0.45; // Weight for 0–25% + float weight2 = 0.35; // Weight for 25–40% + float weight3 = 0.20; // Weight for 40–100% + float totalWeight = weight1 + weight2 + weight3; + + int seg1 = chutil_bar_width * (weight1 / totalWeight); + int seg2 = chutil_bar_width * (weight2 / totalWeight); + int seg3 = chutil_bar_width * (weight3 / totalWeight); + + int fillRight = 0; + + if (chutil_percent <= milestone1) { + fillRight = (seg1 * (chutil_percent / milestone1)); + } else if (chutil_percent <= milestone2) { + fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1))); + } else { + fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2))); + } + + // Draw outline + display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height); + + // Fill progress + if (fillRight > 0) { + display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height); + } + + display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line], + chUtilPercentage); + + if (!config.bluetooth.enabled) { + display->drawString(SCREEN_WIDTH - display->getStringWidth("BT off"), getTextPositions(display)[line], "BT off"); + } + + line += 1; + + // === Fourth & Fifth Rows: Node Identity === + int textWidth = 0; + int nameX = 0; + int yOffset = (SCREEN_WIDTH > 128) ? 0 : 5; + const char *longName = nullptr; + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) { + longName = ourNode->user.long_name; + } + uint8_t dmac[6]; + char shortnameble[35]; + getMacAddr(dmac); + snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]); + snprintf(shortnameble, sizeof(shortnameble), "%s", + graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); + + char combinedName[50]; + snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortnameble); + if (SCREEN_WIDTH - (display->getStringWidth(longName) + display->getStringWidth(shortnameble)) > 10) { + size_t len = strlen(combinedName); + if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) { + combinedName[len - 3] = '\0'; // Remove the last three characters + } + textWidth = display->getStringWidth(combinedName); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString( + nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, combinedName); + } else { + // === LongName Centered === + textWidth = display->getStringWidth(longName); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, getTextPositions(display)[line++], longName); + + // === ShortName Centered === + textWidth = display->getStringWidth(shortnameble); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, getTextPositions(display)[line++], shortnameble); + } +} + +// Start Functions to write date/time to the screen +// Helper function to check if a year is a leap year +bool isLeapYear(int year) +{ + return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)); +} + +// Array of days in each month (non-leap year) +const int daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + +// Fills the buffer with a formatted date/time string and returns pixel width +int UIRenderer::formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLEDDisplay *display, bool includeTime) +{ + int sec = rtc_sec % 60; + rtc_sec /= 60; + int min = rtc_sec % 60; + rtc_sec /= 60; + int hour = rtc_sec % 24; + rtc_sec /= 24; + + int year = 1970; + while (true) { + int daysInYear = isLeapYear(year) ? 366 : 365; + if (rtc_sec >= (uint32_t)daysInYear) { + rtc_sec -= daysInYear; + year++; + } else { + break; + } + } + + int month = 0; + while (month < 12) { + int dim = daysInMonth[month]; + if (month == 1 && isLeapYear(year)) + dim++; + if (rtc_sec >= (uint32_t)dim) { + rtc_sec -= dim; + month++; + } else { + break; + } + } + + int day = rtc_sec + 1; + + if (includeTime) { + snprintf(buf, bufSize, "%04d-%02d-%02d %02d:%02d:%02d", year, month + 1, day, hour, min, sec); + } else { + snprintf(buf, bufSize, "%04d-%02d-%02d", year, month + 1, day); + } + + return display->getStringWidth(buf); +} + +// Check if the display can render a string (detect special chars; emoji) +bool UIRenderer::haveGlyphs(const char *str) +{ +#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS) + // Don't want to make any assumptions about custom language support + return true; +#endif + + // Check each character with the lookup function for the OLED library + // We're not really meant to use this directly.. + bool have = true; + for (uint16_t i = 0; i < strlen(str); i++) { + uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]); + // If font doesn't support a character, it is substituted for ¿ + if (result == 191 && (uint8_t)str[i] != 191) { + have = false; + break; + } + } + + // LOG_DEBUG("haveGlyphs=%d", have); + return have; +} + +#ifdef USE_EINK +/// Used on eink displays while in deep sleep +void UIRenderer::drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + + // Next frame should use full-refresh, and block while running, else device will sleep before async callback + EINK_ADD_FRAMEFLAG(display, COSMETIC); + EINK_ADD_FRAMEFLAG(display, BLOCKING); + + LOG_DEBUG("Draw deep sleep screen"); + + // Display displayStr on the screen + graphics::UIRenderer::drawIconScreen("Sleeping", display, state, x, y); +} + +/// Used on eink displays when screen updates are paused +void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + LOG_DEBUG("Draw screensaver overlay"); + + EINK_ADD_FRAMEFLAG(display, COSMETIC); // Take the opportunity for a full-refresh + + // Config + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + const char *pauseText = "Screen Paused"; + const char *idText = owner.short_name; + const bool useId = haveGlyphs(idText); // This bool is used to hide the idText box if we can't render the short name + constexpr uint16_t padding = 5; + constexpr uint8_t dividerGap = 1; + constexpr uint8_t imprecision = 5; // How far the box origins can drift from center. Combat burn-in. + + // Dimensions + const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true); // "true": handle utf8 chars + const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText)); + const uint16_t boxWidth = padding + (useId ? idTextWidth + padding + padding : 0) + pauseTextWidth + padding; + const uint16_t boxHeight = padding + FONT_HEIGHT_SMALL + padding; + + // Position + const int16_t boxLeft = (display->width() / 2) - (boxWidth / 2) + random(-imprecision, imprecision + 1); + // const int16_t boxRight = boxLeft + boxWidth - 1; + const int16_t boxTop = (display->height() / 2) - (boxHeight / 2 + random(-imprecision, imprecision + 1)); + const int16_t boxBottom = boxTop + boxHeight - 1; + const int16_t idTextLeft = boxLeft + padding; + const int16_t idTextTop = boxTop + padding; + const int16_t pauseTextLeft = boxLeft + (useId ? padding + idTextWidth + padding : 0) + padding; + const int16_t pauseTextTop = boxTop + padding; + const int16_t dividerX = boxLeft + padding + idTextWidth + padding; + const int16_t dividerTop = boxTop + 1 + dividerGap; + const int16_t dividerBottom = boxBottom - 1 - dividerGap; + + // Draw: box + display->setColor(EINK_WHITE); + display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Clear a slightly oversized area for the box + display->setColor(EINK_BLACK); + display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); + + // Draw: Text + if (useId) + display->drawString(idTextLeft, idTextTop, idText); + display->drawString(pauseTextLeft, pauseTextTop, pauseText); + display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold + + // Draw: divider + if (useId) + display->drawLine(dividerX, dividerTop, dividerX, dividerBottom); +} +#endif + +/** + * Draw the icon with extra info printed around the corners + */ +void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + // draw an xbm image. + // Please note that everything that should be transitioned + // needs to be drawn relative to x and y + + // draw centered icon left to right and centered above the one line of app text + display->drawXbm(x + (SCREEN_WIDTH - icon_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - icon_height) / 2 + 2, + icon_width, icon_height, icon_bits); + + display->setFont(FONT_MEDIUM); + display->setTextAlignment(TEXT_ALIGN_LEFT); + const char *title = "meshtastic.org"; + display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); + display->setFont(FONT_SMALL); + + // Draw region in upper left + if (upperMsg) + display->drawString(x + 0, y + 0, upperMsg); + + // Draw version and short name in upper right + char buf[25]; + snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), + graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); + + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(x + SCREEN_WIDTH, y + 0, buf); + screen->forceDisplay(); + + display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code +} + +// **************************** +// * My Position Screen * +// **************************** +void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + int line = 1; + + // === Set Title + const char *titleStr = "Position"; + + // === Header === + graphics::drawCommonHeader(display, x, y, titleStr); + + // === First Row: My Location === +#if HAS_GPS + bool origBold = config.display.heading_bold; + config.display.heading_bold = false; + + const char *displayLine = ""; // Initialize to empty string by default + if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + if (config.position.fixed_position) { + displayLine = "Fixed GPS"; + } else { + displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; + } + int yOffset = (SCREEN_WIDTH > 128) ? 3 : 1; + if (SCREEN_WIDTH > 128) { + NodeListRenderer::drawScaledXBitmap16x16(x, getTextPositions(display)[line] + yOffset - 5, imgSatellite_width, + imgSatellite_height, imgSatellite, display); + } else { + display->drawXbm(x + 1, getTextPositions(display)[line] + yOffset, imgSatellite_width, imgSatellite_height, + imgSatellite); + } + int xOffset = (SCREEN_WIDTH > 128) ? 6 : 0; + display->drawString(x + 11 + xOffset, getTextPositions(display)[line++], displayLine); + } else { + UIRenderer::drawGps(display, 0, getTextPositions(display)[line++], gpsStatus); + } + + config.display.heading_bold = origBold; + + // === Update GeoCoord === + geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()), + int32_t(gpsStatus->getAltitude())); + + // === Determine Compass Heading === + float heading; + bool validHeading = false; + + if (screen->hasHeading()) { + heading = radians(screen->getHeading()); + validHeading = true; + } else { + heading = screen->estimatedHeading(geoCoord.getLatitude() * 1e-7, geoCoord.getLongitude() * 1e-7); + validHeading = !isnan(heading); + } + + // If GPS is off, no need to display these parts + if (strcmp(displayLine, "GPS off") != 0 && strcmp(displayLine, "No GPS") != 0) { + + // === Second Row: Date === + uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); + char datetimeStr[25]; + bool showTime = false; // set to true for full datetime + UIRenderer::formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display, showTime); + char fullLine[40]; + snprintf(fullLine, sizeof(fullLine), " Date: %s", datetimeStr); + display->drawString(0, getTextPositions(display)[line++], fullLine); + + // === Third Row: Latitude === + char latStr[32]; + snprintf(latStr, sizeof(latStr), " Lat: %.5f", geoCoord.getLatitude() * 1e-7); + display->drawString(x, getTextPositions(display)[line++], latStr); + + // === Fourth Row: Longitude === + char lonStr[32]; + snprintf(lonStr, sizeof(lonStr), " Lon: %.5f", geoCoord.getLongitude() * 1e-7); + display->drawString(x, getTextPositions(display)[line++], lonStr); + + // === Fifth Row: Altitude === + char DisplayLineTwo[32] = {0}; + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { + snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), " Alt: %.0fft", geoCoord.getAltitude() * METERS_TO_FEET); + } else { + snprintf(DisplayLineTwo, sizeof(DisplayLineTwo), " Alt: %.0im", geoCoord.getAltitude()); + } + display->drawString(x, getTextPositions(display)[line++], DisplayLineTwo); + } + + // === Draw Compass if heading is valid === + if (validHeading) { + // --- Compass Rendering: landscape (wide) screens use original side-aligned logic --- + if (SCREEN_WIDTH > SCREEN_HEIGHT) { + const int16_t topY = getTextPositions(display)[1]; + const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height + const int16_t usableHeight = bottomY - topY - 5; + + int16_t compassRadius = usableHeight / 2; + if (compassRadius < 8) + compassRadius = 8; + const int16_t compassDiam = compassRadius * 2; + const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8; + + // Center vertically and nudge down slightly to keep "N" clear of header + const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; + + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading); + display->drawCircle(compassX, compassY, compassRadius); + + // "N" label + float northAngle = -heading; + float radius = compassRadius; + int16_t nX = compassX + (radius - 1) * sin(northAngle); + int16_t nY = compassY - (radius - 1) * cos(northAngle); + int16_t nLabelWidth = display->getStringWidth("N") + 2; + int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; + + display->setColor(BLACK); + display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); + display->setColor(WHITE); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); + } else { + // Portrait or square: put compass at the bottom and centered, scaled to fit available space + // For E-Ink screens, account for navigation bar at the bottom! + int yBelowContent = getTextPositions(display)[5] + FONT_HEIGHT_SMALL + 2; + const int margin = 4; + int availableHeight = +#if defined(USE_EINK) + SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink +#else + SCREEN_HEIGHT - yBelowContent - margin; +#endif + + if (availableHeight < FONT_HEIGHT_SMALL * 2) + return; + + int compassRadius = availableHeight / 2; + if (compassRadius < 8) + compassRadius = 8; + if (compassRadius * 2 > SCREEN_WIDTH - 16) + compassRadius = (SCREEN_WIDTH - 16) / 2; + + int compassX = x + SCREEN_WIDTH / 2; + int compassY = yBelowContent + availableHeight / 2; + + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading); + display->drawCircle(compassX, compassY, compassRadius); + + // "N" label + float northAngle = -heading; + float radius = compassRadius; + int16_t nX = compassX + (radius - 1) * sin(northAngle); + int16_t nY = compassY - (radius - 1) * cos(northAngle); + int16_t nLabelWidth = display->getStringWidth("N") + 2; + int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; + + display->setColor(BLACK); + display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); + display->setColor(WHITE); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); + } + } +#endif +} + +#ifdef USERPREFS_OEM_TEXT + +void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + static const uint8_t xbm[] = USERPREFS_OEM_IMAGE_DATA; + display->drawXbm(x + (SCREEN_WIDTH - USERPREFS_OEM_IMAGE_WIDTH) / 2, + y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - USERPREFS_OEM_IMAGE_HEIGHT) / 2 + 2, USERPREFS_OEM_IMAGE_WIDTH, + USERPREFS_OEM_IMAGE_HEIGHT, xbm); + + switch (USERPREFS_OEM_FONT_SIZE) { + case 0: + display->setFont(FONT_SMALL); + break; + case 2: + display->setFont(FONT_LARGE); + break; + default: + display->setFont(FONT_MEDIUM); + break; + } + + display->setTextAlignment(TEXT_ALIGN_LEFT); + const char *title = USERPREFS_OEM_TEXT; + display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); + display->setFont(FONT_SMALL); + + // Draw region in upper left + if (upperMsg) + display->drawString(x + 0, y + 0, upperMsg); + + // Draw version and shortname in upper right + char buf[25]; + snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); + + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(x + SCREEN_WIDTH, y + 0, buf); + screen->forceDisplay(); + + display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code +} + +void UIRenderer::drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + // Draw region in upper left + const char *region = myRegion ? myRegion->name : NULL; + drawOEMIconScreen(region, display, state, x, y); +} + +#endif + +// Function overlay for showing mute/buzzer modifiers etc. +void UIRenderer::drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + // LOG_DEBUG("Draw function overlay"); + if (functionSymbol.begin() != functionSymbol.end()) { + char buf[64]; + display->setFont(FONT_SMALL); + snprintf(buf, sizeof(buf), "%s", functionSymbolString.c_str()); + display->drawString(SCREEN_WIDTH - display->getStringWidth(buf), SCREEN_HEIGHT - FONT_HEIGHT_SMALL, buf); + } +} + +// Navigation bar overlay implementation +static int8_t lastFrameIndex = -1; +static uint32_t lastFrameChangeTime = 0; +constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000; + +void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + int currentFrame = state->currentFrame; + + // Detect frame change and record time + if (currentFrame != lastFrameIndex) { + lastFrameIndex = currentFrame; + lastFrameChangeTime = millis(); + } + + const bool useBigIcons = (SCREEN_WIDTH > 128); + const int iconSize = useBigIcons ? 16 : 8; + const int spacing = useBigIcons ? 8 : 4; + const int bigOffset = useBigIcons ? 1 : 0; + + const size_t totalIcons = screen->indicatorIcons.size(); + if (totalIcons == 0) + return; + + const size_t iconsPerPage = (SCREEN_WIDTH + spacing) / (iconSize + spacing); + const size_t currentPage = currentFrame / iconsPerPage; + const size_t pageStart = currentPage * iconsPerPage; + const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons); + + const int totalWidth = (pageEnd - pageStart) * iconSize + (pageEnd - pageStart - 1) * spacing; + const int xStart = (SCREEN_WIDTH - totalWidth) / 2; + + // Only show bar briefly after switching frames (unless on E-Ink) +#if defined(USE_EINK) + int y = SCREEN_HEIGHT - iconSize - 1; +#else + int y = SCREEN_HEIGHT - iconSize - 1; + if (millis() - lastFrameChangeTime > ICON_DISPLAY_DURATION_MS) { + y = SCREEN_HEIGHT; + } +#endif + + // Pre-calculate bounding rect + const int rectX = xStart - 2 - bigOffset; + const int rectWidth = totalWidth + 4 + (bigOffset * 2); + const int rectHeight = iconSize + 6; + + // Clear background and draw border + display->setColor(BLACK); + display->fillRect(rectX + 1, y - 2, rectWidth - 2, rectHeight - 2); + display->setColor(WHITE); + display->drawRect(rectX, y - 2, rectWidth, rectHeight); + + // Icon drawing loop for the current page + for (size_t i = pageStart; i < pageEnd; ++i) { + const uint8_t *icon = screen->indicatorIcons[i]; + const int x = xStart + (i - pageStart) * (iconSize + spacing); + const bool isActive = (i == static_cast(currentFrame)); + + if (isActive) { + display->setColor(WHITE); + display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4); + display->setColor(BLACK); + } + + if (useBigIcons) { + NodeListRenderer::drawScaledXBitmap16x16(x, y, 8, 8, icon, display); + } else { + display->drawXbm(x, y, iconSize, iconSize, icon); + } + + if (isActive) { + display->setColor(WHITE); + } + } + + // Knock the corners off the square + display->setColor(BLACK); + display->drawRect(rectX, y - 2, 1, 1); + display->drawRect(rectX + rectWidth - 1, y - 2, 1, 1); + display->setColor(WHITE); +} + +void UIRenderer::drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *message) +{ + uint16_t x_offset = display->width() / 2; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_MEDIUM); + display->drawString(x_offset + x, 26 + y, message); +} + +std::string UIRenderer::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds) +{ + std::string uptime; + + if (days > (HOURS_IN_MONTH * 6)) + uptime = "?"; + else if (days >= 2) + uptime = std::to_string(days) + "d"; + else if (hours >= 2) + uptime = std::to_string(hours) + "h"; + else if (minutes >= 1) + uptime = std::to_string(minutes) + "m"; + else + uptime = std::to_string(seconds) + "s"; + return uptime; +} + +} // namespace graphics + +#endif // !MESHTASTIC_EXCLUDE_GPS +#endif // HAS_SCREEN \ No newline at end of file diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h new file mode 100644 index 000000000..21e4aef61 --- /dev/null +++ b/src/graphics/draw/UIRenderer.h @@ -0,0 +1,93 @@ +#pragma once + +#include "graphics/Screen.h" +#include "graphics/emotes.h" +#include +#include +#include + +#define HOURS_IN_MONTH 730 + +// Forward declarations for status types +namespace meshtastic +{ +class PowerStatus; +class NodeStatus; +class GPSStatus; +} // namespace meshtastic + +namespace graphics +{ + +/// Forward declarations +class Screen; + +/** + * @brief UI utility drawing functions + * + * Contains utility functions for drawing common UI elements, overlays, + * battery indicators, and other shared graphical components. + */ +class UIRenderer +{ + public: + // Common UI elements + static void drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer, + const meshtastic::PowerStatus *powerStatus); + static void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus, + int node_offset = 0, bool show_total = true, String additional_words = ""); + + // GPS status functions + static void drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus); + static void drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus); + static void drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus); + static void drawGpsPowerStatus(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus); + + // Layout and utility functions + static void drawScrollbar(OLEDDisplay *display, int visibleItems, int totalItems, int scrollIndex, int x, int startY); + + // Overlay and special screens + static void drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *text); + + // Function overlay for showing mute/buzzer modifiers etc. + static void drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); + + // Navigation bar overlay + static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state); + + static void drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y); + + static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + + // Icon and screen drawing functions + static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + + // Compass and location screen + static void drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + + static NodeNum currentFavoriteNodeNum; + +// OEM screens +#ifdef USERPREFS_OEM_TEXT + static void drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +#endif + +#ifdef USE_EINK + /// Used on eink displays while in deep sleep + static void drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + + /// Used on eink displays when screen updates are paused + static void drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); +#endif + + static std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds); + static int formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime); + + // Message filtering + static bool shouldDrawMessage(const meshtastic_MeshPacket *packet); + // Check if the display can render a string (detect special chars; emoji) + static bool haveGlyphs(const char *str); +}; // namespace UIRenderer + +} // namespace graphics diff --git a/src/graphics/emotes.cpp b/src/graphics/emotes.cpp new file mode 100644 index 000000000..205d5c660 --- /dev/null +++ b/src/graphics/emotes.cpp @@ -0,0 +1,225 @@ +#include "emotes.h" + +namespace graphics +{ + +// Always define Emote list and count +const Emote emotes[] = { +#ifndef EXCLUDE_EMOJI + // --- Thumbs --- + {"\U0001F44D", thumbup, thumbs_width, thumbs_height}, // 👍 Thumbs Up + {"\U0001F44E", thumbdown, thumbs_width, thumbs_height}, // 👎 Thumbs Down + + // --- Smileys (Multiple Unicode Aliases) --- + {"\U0001F60A", smiley, smiley_width, smiley_height}, // 😊 Smiling Face with Smiling Eyes + {"\U0001F600", smiley, smiley_width, smiley_height}, // 😀 Grinning Face + {"\U0001F642", smiley, smiley_width, smiley_height}, // 🙂 Slightly Smiling Face + {"\U0001F609", smiley, smiley_width, smiley_height}, // 😉 Winking Face + {"\U0001F601", smiley, smiley_width, smiley_height}, // 😁 Grinning Face with Smiling Eyes + + // --- Question/Alert --- + {"\u2753", question, question_width, question_height}, // ❓ Question Mark + {"\u203C\uFE0F", bang, bang_width, bang_height}, // ‼️ Double Exclamation Mark + + // --- Laughing Faces --- + {"\U0001F602", haha, haha_width, haha_height}, // 😂 Face with Tears of Joy + {"\U0001F923", haha, haha_width, haha_height}, // 🤣 Rolling on the Floor Laughing + {"\U0001F606", haha, haha_width, haha_height}, // 😆 Smiling with Open Mouth and Closed Eyes + {"\U0001F605", haha, haha_width, haha_height}, // 😅 Smiling with Sweat + {"\U0001F604", haha, haha_width, haha_height}, // 😄 Grinning Face with Smiling Eyes + + // --- Gestures and People --- + {"\U0001F44B", wave_icon, wave_icon_width, wave_icon_height}, // 👋 Waving Hand + {"\U0001F920", cowboy, cowboy_width, cowboy_height}, // 🤠 Cowboy Hat Face + {"\U0001F3A7", deadmau5, deadmau5_width, deadmau5_height}, // 🎧 Headphones + + // --- Weather --- + {"\u2600", sun, sun_width, sun_height}, // ☀ Sun (without variation selector) + {"\u2600\uFE0F", sun, sun_width, sun_height}, // ☀️ Sun (with variation selector) + {"\U0001F327\uFE0F", rain, rain_width, rain_height}, // 🌧️ Cloud with Rain + {"\u2601\uFE0F", cloud, cloud_width, cloud_height}, // ☁️ Cloud + {"\U0001F32B\uFE0F", fog, fog_width, fog_height}, // 🌫️ Fog + + // --- Misc Faces --- + {"\U0001F608", devil, devil_width, devil_height}, // 😈 Smiling Face with Horns + + // --- Hearts (Multiple Unicode Aliases) --- + {"\u2764\uFE0F", heart, heart_width, heart_height}, // ❤️ Red Heart + {"\U0001F9E1", heart, heart_width, heart_height}, // 🧡 Orange Heart + {"\U00002763", heart, heart_width, heart_height}, // ❣ Heart Exclamation + {"\U00002764", heart, heart_width, heart_height}, // ❤ Red Heart (legacy) + {"\U0001F495", heart, heart_width, heart_height}, // 💕 Two Hearts + {"\U0001F496", heart, heart_width, heart_height}, // 💖 Sparkling Heart + {"\U0001F497", heart, heart_width, heart_height}, // 💗 Growing Heart + {"\U0001F498", heart, heart_width, heart_height}, // 💘 Heart with Arrow + + // --- Objects --- + {"\U0001F4A9", poo, poo_width, poo_height}, // 💩 Pile of Poo + {"\U0001F514", bell_icon, bell_icon_width, bell_icon_height} // 🔔 Bell +#endif +}; + +const int numEmotes = sizeof(emotes) / sizeof(emotes[0]); + +#ifndef EXCLUDE_EMOJI +const unsigned char thumbup[] PROGMEM = { + 0x00, 0x1C, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x80, 0x09, 0x00, 0x00, + 0xC0, 0x08, 0x00, 0x00, 0x40, 0x08, 0x00, 0x00, 0x20, 0x04, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, + 0x0C, 0xCE, 0x7F, 0x00, 0x04, 0x20, 0x80, 0x00, 0x02, 0x20, 0x80, 0x00, 0x02, 0x60, 0xC0, 0x00, 0x01, 0xF8, 0xFF, 0x01, + 0x01, 0x08, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01, 0x01, 0xF8, 0xFF, 0x00, 0x01, 0x10, 0x80, 0x00, 0x01, 0x18, 0x80, 0x00, + 0x02, 0x30, 0xC0, 0x00, 0x06, 0xE0, 0x3F, 0x00, 0x0C, 0x20, 0x30, 0x00, 0x38, 0x20, 0x10, 0x00, 0xE0, 0xCF, 0x1F, 0x00, +}; + +const unsigned char thumbdown[] PROGMEM = { + 0xE0, 0xCF, 0x1F, 0x00, 0x38, 0x20, 0x10, 0x00, 0x0C, 0x20, 0x30, 0x00, 0x06, 0xE0, 0x3F, 0x00, 0x02, 0x30, 0xC0, 0x00, + 0x01, 0x18, 0x80, 0x00, 0x01, 0x10, 0x80, 0x00, 0x01, 0xF8, 0xFF, 0x00, 0x01, 0x08, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01, + 0x01, 0xF8, 0xFF, 0x01, 0x02, 0x60, 0xC0, 0x00, 0x02, 0x20, 0x80, 0x00, 0x04, 0x20, 0x80, 0x00, 0x0C, 0xCE, 0x7F, 0x00, + 0x18, 0x02, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x20, 0x04, 0x00, 0x00, 0x40, 0x08, 0x00, 0x00, 0xC0, 0x08, 0x00, 0x00, + 0x80, 0x09, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x00, +}; + +const unsigned char smiley[] PROGMEM = { + 0x00, 0xfe, 0x0f, 0x00, 0x80, 0x01, 0x30, 0x00, 0x40, 0x00, 0xc0, 0x00, 0x20, 0x00, 0x00, 0x01, 0x10, 0x00, 0x00, 0x02, + 0x08, 0x00, 0x00, 0x04, 0x04, 0x00, 0x00, 0x08, 0x04, 0x00, 0x00, 0x10, 0x02, 0x0e, 0x0e, 0x10, 0x02, 0x09, 0x12, 0x10, + 0x01, 0x09, 0x12, 0x20, 0x01, 0x0f, 0x1e, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, + 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x81, 0x00, 0x20, 0x20, + 0x82, 0x00, 0x20, 0x10, 0x02, 0x01, 0x10, 0x10, 0x04, 0x02, 0x08, 0x08, 0x04, 0xfc, 0x07, 0x08, 0x08, 0x00, 0x00, 0x04, + 0x10, 0x00, 0x00, 0x02, 0x20, 0x00, 0x00, 0x01, 0x40, 0x00, 0xc0, 0x00, 0x80, 0x01, 0x30, 0x00, 0x00, 0xfe, 0x0f, 0x00}; + +const unsigned char question[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x80, 0xFF, 0x01, 0x00, 0xC0, 0xFF, 0x07, 0x00, 0xE0, 0xFF, 0x07, 0x00, + 0xE0, 0xC3, 0x0F, 0x00, 0xF0, 0x81, 0x0F, 0x00, 0xF0, 0x01, 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x80, 0x0F, 0x00, + 0x00, 0xC0, 0x0F, 0x00, 0x00, 0xE0, 0x07, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0xF8, 0x01, 0x00, 0x00, 0x7C, 0x00, 0x00, + 0x00, 0x3C, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const unsigned char bang[] PROGMEM = { + 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x07, 0xF8, 0x3F, 0xFF, 0x07, 0xF8, 0x3F, + 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, + 0xFE, 0x03, 0xF0, 0x1F, 0xFE, 0x03, 0xF0, 0x1F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, + 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x01, 0xE0, 0x0F, 0xFC, 0x01, 0xE0, 0x0F, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0xC0, 0x03, 0xFC, 0x03, 0xF0, 0x0F, 0xFE, 0x03, 0xF0, 0x1F, + 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFC, 0x03, 0xF0, 0x0F, 0xF8, 0x01, 0xE0, 0x07, +}; + +const unsigned char haha[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x01, 0x00, + 0x00, 0xFC, 0x0F, 0x00, 0x00, 0x1F, 0x3E, 0x00, 0x80, 0x03, 0x70, 0x00, 0xC0, 0x01, 0xE0, 0x00, 0xC0, 0x00, 0xC2, 0x00, + 0x60, 0x00, 0x03, 0x00, 0x60, 0x00, 0xC1, 0x1F, 0x60, 0x80, 0x8F, 0x31, 0x30, 0x0E, 0x80, 0x31, 0x30, 0x10, 0x30, 0x1F, + 0x30, 0x08, 0x58, 0x00, 0x30, 0x04, 0x6C, 0x03, 0x60, 0x00, 0xF3, 0x01, 0x60, 0xC0, 0xFC, 0x01, 0x80, 0x38, 0xBF, 0x01, + 0xE0, 0xC5, 0xDF, 0x00, 0xB0, 0xF9, 0xEF, 0x00, 0x30, 0xF1, 0x73, 0x00, 0xB0, 0x1D, 0x3E, 0x00, 0xF0, 0xFD, 0x0F, 0x00, + 0xE0, 0xE0, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const unsigned char wave_icon[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0xC0, 0x00, + 0x00, 0x0C, 0x9C, 0x01, 0x80, 0x17, 0x20, 0x01, 0x80, 0x26, 0x46, 0x02, 0x80, 0x44, 0x88, 0x02, 0xC0, 0x89, 0x8A, 0x02, + 0x40, 0x93, 0x8B, 0x02, 0x40, 0x26, 0x13, 0x00, 0x80, 0x44, 0x16, 0x00, 0xC0, 0x89, 0x24, 0x00, 0x40, 0x93, 0x60, 0x00, + 0x40, 0x26, 0x40, 0x00, 0x80, 0x0C, 0x80, 0x00, 0x00, 0x09, 0x80, 0x00, 0x00, 0x02, 0x80, 0x00, 0x40, 0x06, 0x80, 0x00, + 0x50, 0x0C, 0x80, 0x00, 0x50, 0x08, 0x40, 0x00, 0x90, 0x10, 0x20, 0x00, 0xB0, 0x21, 0x10, 0x00, 0x20, 0x47, 0x18, 0x00, + 0x40, 0x80, 0x0F, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const unsigned char cowboy[] PROGMEM = { + 0x00, 0xF0, 0x03, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0x00, 0xFE, 0x1F, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x3C, 0xFE, 0x1F, 0x0F, + 0xFE, 0xFE, 0xDF, 0x1F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, + 0x3E, 0xC0, 0x00, 0x1F, 0x1E, 0x00, 0x00, 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x08, 0x0E, 0x1C, 0x04, 0x00, 0x0E, 0x1C, 0x00, + 0x04, 0x0E, 0x1C, 0x08, 0x04, 0x0E, 0x1C, 0x08, 0x04, 0x04, 0x08, 0x08, 0x04, 0x00, 0x00, 0x08, 0x04, 0x00, 0x00, 0x08, + 0x8C, 0x07, 0x70, 0x0C, 0x88, 0xFC, 0x4F, 0x04, 0x88, 0x01, 0x40, 0x04, 0x90, 0xFF, 0x7F, 0x02, 0x30, 0x03, 0x30, 0x03, + 0x60, 0x0E, 0x9C, 0x01, 0xC0, 0xF8, 0xC7, 0x00, 0x80, 0x01, 0x60, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, 0xF8, 0x07, 0x00, +}; + +const unsigned char deadmau5[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x07, 0x00, + 0x00, 0xFC, 0x03, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x80, 0xFF, 0x0F, 0x00, 0xC0, 0xFF, 0xFF, 0x00, 0xE0, 0xFF, 0x3F, 0x00, + 0xE0, 0xFF, 0xFF, 0x01, 0xF0, 0xFF, 0x7F, 0x00, 0xF0, 0xFF, 0xFF, 0x03, 0xF8, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x07, + 0xFC, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x0F, 0xFC, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x0F, 0xFE, 0xFF, 0xFF, 0x00, + 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xE0, 0xFF, 0x3F, 0xFC, + 0x0F, 0xFF, 0x7F, 0x00, 0xC0, 0xFF, 0x1F, 0xF8, 0x0F, 0xFC, 0x3F, 0x00, 0x80, 0xFF, 0x0F, 0xF8, 0x1F, 0xFC, 0x1F, 0x00, + 0x00, 0xFF, 0x0F, 0xFC, 0x3F, 0xFC, 0x0F, 0x00, 0x00, 0xF8, 0x1F, 0xFF, 0xFF, 0xFE, 0x01, 0x00, 0x00, 0x00, 0xFC, 0xFF, + 0xFF, 0x07, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x18, 0x00, + 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xC0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x80, 0x07, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const unsigned char sun[] PROGMEM = { + 0x00, 0xC0, 0x00, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x30, 0xC0, 0x00, 0x03, + 0x70, 0x00, 0x80, 0x03, 0xF0, 0x00, 0xC0, 0x03, 0xF0, 0xF8, 0xC7, 0x03, 0xE0, 0xFC, 0xCF, 0x01, 0x00, 0xFE, 0x1F, 0x00, + 0x00, 0xFF, 0x3F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x8E, 0xFF, 0x7F, 0x1C, 0x9F, 0xFF, 0x7F, 0x3E, + 0x9F, 0xFF, 0x7F, 0x3E, 0x8E, 0xFF, 0x7F, 0x1C, 0x80, 0xFF, 0x7F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0xFF, 0x3F, 0x00, + 0x00, 0xFE, 0x1F, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0xC0, 0xF9, 0xE7, 0x00, 0xE0, 0x01, 0xE0, 0x01, 0xF0, 0x01, 0xE0, 0x03, + 0xF0, 0xC0, 0xC0, 0x03, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xC0, 0x00, 0x00, +}; + +const unsigned char rain[] PROGMEM = { + 0xC0, 0x0F, 0xC0, 0x00, 0x40, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x03, 0x38, 0x00, + 0x00, 0x0E, 0x0C, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, 0x10, 0x03, 0x00, 0x00, 0x30, 0x01, 0x00, 0x00, 0x20, + 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x03, 0x00, 0x00, 0x30, 0x02, 0x00, + 0x00, 0x10, 0x06, 0x00, 0x00, 0x08, 0xFC, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x01, 0x80, 0x00, 0x01, 0x00, + 0xC0, 0xC0, 0x81, 0x03, 0xA0, 0x60, 0xC1, 0x03, 0x90, 0x20, 0x41, 0x01, 0xF0, 0xE0, 0xC0, 0x01, 0x60, 0x4C, + 0x98, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, 0x0B, 0x12, 0x00, 0x00, 0x09, 0x1A, 0x00, 0x00, 0x06, 0x0E, 0x00, +}; + +const unsigned char cloud[] PROGMEM = { + 0x00, 0x80, 0x07, 0x00, 0x00, 0xE0, 0x1F, 0x00, 0x00, 0x70, 0x30, 0x00, 0x00, 0x10, 0x60, 0x00, 0x80, 0x1F, 0x40, 0x00, + 0xC0, 0x0F, 0xC0, 0x00, 0xC0, 0x00, 0x80, 0x00, 0x60, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x01, + 0x20, 0x00, 0x00, 0x07, 0x38, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x08, 0x06, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, 0x10, + 0x02, 0x00, 0x00, 0x30, 0x03, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, + 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x30, 0x03, 0x00, 0x00, 0x10, + 0x02, 0x00, 0x00, 0x10, 0x06, 0x00, 0x00, 0x18, 0x0C, 0x00, 0x00, 0x0C, 0xFC, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x03, +}; + +const unsigned char fog[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x78, 0x00, 0x3C, 0x00, 0xFE, 0x01, 0xFF, 0x00, 0x87, 0xC7, 0xC3, 0x01, 0x03, 0xFE, 0x80, 0x01, + 0x00, 0x38, 0x00, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0xFF, 0x83, 0xFF, 0x01, 0x03, 0xFF, 0x81, 0x01, 0x00, 0x7C, 0x00, 0x00, + 0xF8, 0x00, 0x3E, 0x00, 0xFE, 0x01, 0xFF, 0x00, 0x87, 0xC7, 0xC3, 0x01, 0x03, 0xFE, 0x80, 0x01, 0x00, 0x38, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const unsigned char devil[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x10, 0x03, 0xC0, 0x01, 0x38, 0x07, 0x7C, 0x0F, 0x38, 0x1F, 0x03, 0x30, 0x1E, + 0xFE, 0x01, 0xE0, 0x1F, 0x7E, 0x00, 0x80, 0x1F, 0x3C, 0x00, 0x00, 0x0F, 0x1C, 0x00, 0x00, 0x0E, 0x18, 0x00, 0x00, 0x06, + 0x08, 0x00, 0x00, 0x04, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x0E, 0x1C, 0x0C, + 0x0C, 0x18, 0x06, 0x0C, 0x0C, 0x1C, 0x06, 0x0C, 0x0C, 0x1C, 0x0E, 0x0C, 0x0C, 0x1C, 0x0E, 0x0C, 0x0C, 0x0C, 0x06, 0x0C, + 0x08, 0x00, 0x00, 0x06, 0x18, 0x02, 0x10, 0x06, 0x10, 0x0C, 0x0C, 0x03, 0x30, 0xF8, 0x07, 0x03, 0x60, 0xE0, 0x80, 0x01, + 0xC0, 0x00, 0xC0, 0x00, 0x80, 0x01, 0x70, 0x00, 0x00, 0x06, 0x1C, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const unsigned char heart[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0xC0, 0x03, 0xF0, 0x00, 0xF8, 0x0F, 0xFC, 0x07, 0xFC, 0x1F, 0x06, 0x0E, 0xFE, 0x3F, 0x03, 0x18, + 0xFE, 0xFF, 0x7F, 0x10, 0xFF, 0xFF, 0xFF, 0x31, 0xFF, 0xFF, 0xFF, 0x33, 0xFF, 0xFF, 0xFF, 0x37, 0xFF, 0xFF, 0xFF, 0x37, + 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFE, 0xFF, 0xFF, 0x1F, 0xFE, 0xFF, 0xFF, 0x1F, + 0xFC, 0xFF, 0xFF, 0x0F, 0xFC, 0xFF, 0xFF, 0x0F, 0xF8, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x03, 0xF0, 0xFF, 0xFF, 0x03, + 0xE0, 0xFF, 0xFF, 0x01, 0xC0, 0xFF, 0xFF, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x00, 0xFE, 0x1F, 0x00, + 0x00, 0xFC, 0x0F, 0x00, 0x00, 0xF8, 0x07, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0x80, 0x00, 0x00, +}; + +const unsigned char poo[] PROGMEM = { + 0x00, 0x1C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0xEC, 0x01, 0x00, 0x00, 0x8C, 0x07, 0x00, 0x00, 0x0C, 0x06, 0x00, + 0x00, 0x24, 0x0C, 0x00, 0x00, 0x34, 0x08, 0x00, 0x00, 0x1F, 0x08, 0x00, 0xC0, 0x0F, 0x08, 0x00, 0xC0, 0x00, 0x3C, 0x00, + 0x60, 0x00, 0x7C, 0x00, 0x60, 0x00, 0xC6, 0x00, 0x20, 0x00, 0xCB, 0x00, 0xA0, 0xC7, 0xFF, 0x00, 0xE0, 0x7F, 0xF7, 0x00, + 0xF0, 0x18, 0xE3, 0x03, 0x78, 0x18, 0x41, 0x03, 0x6C, 0x9B, 0x5D, 0x06, 0x64, 0x9B, 0x5D, 0x04, 0x44, 0x1A, 0x41, 0x04, + 0x4C, 0xD8, 0x63, 0x06, 0xF8, 0xFC, 0x36, 0x06, 0xFE, 0x0F, 0x9C, 0x1F, 0x07, 0x03, 0xC0, 0x30, 0x03, 0x00, 0x78, 0x20, + 0x01, 0x00, 0x1F, 0x20, 0x03, 0xE0, 0x03, 0x20, 0x07, 0x7E, 0x04, 0x30, 0xFE, 0x0F, 0xFC, 0x1F, 0xF0, 0x00, 0xF0, 0x0F, +}; + +const unsigned char bell_icon[] PROGMEM = { + 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b11110000, + 0b00000011, 0b00000000, 0b00000000, 0b11111100, 0b00001111, 0b00000000, 0b00000000, 0b00001111, 0b00111100, 0b00000000, + 0b00000000, 0b00000011, 0b00110000, 0b00000000, 0b10000000, 0b00000001, 0b01100000, 0b00000000, 0b11000000, 0b00000000, + 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, + 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, + 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b11000000, 0b00000000, + 0b11000000, 0b00000000, 0b11000000, 0b00000000, 0b01000000, 0b00000000, 0b10000000, 0b00000000, 0b01100000, 0b00000000, + 0b10000000, 0b00000001, 0b01110000, 0b00000000, 0b10000000, 0b00000011, 0b00110000, 0b00000000, 0b00000000, 0b00000011, + 0b00011000, 0b00000000, 0b00000000, 0b00000110, 0b11110000, 0b11111111, 0b11111111, 0b00000011, 0b00000000, 0b00001100, + 0b00001100, 0b00000000, 0b00000000, 0b00011000, 0b00000110, 0b00000000, 0b00000000, 0b11111000, 0b00000111, 0b00000000, + 0b00000000, 0b11100000, 0b00000001, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, + 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000}; +#endif + +} // namespace graphics diff --git a/src/graphics/emotes.h b/src/graphics/emotes.h new file mode 100644 index 000000000..5640ac04a --- /dev/null +++ b/src/graphics/emotes.h @@ -0,0 +1,86 @@ +#pragma once +#include + +namespace graphics +{ + +// === Emote List === +struct Emote { + const char *label; + const unsigned char *bitmap; + int width; + int height; +}; + +extern const Emote emotes[/* numEmotes */]; +extern const int numEmotes; + +#ifndef EXCLUDE_EMOJI +// === Emote Bitmaps === +#define thumbs_height 25 +#define thumbs_width 25 +extern const unsigned char thumbup[] PROGMEM; +extern const unsigned char thumbdown[] PROGMEM; + +#define smiley_height 30 +#define smiley_width 30 +extern const unsigned char smiley[] PROGMEM; + +#define question_height 25 +#define question_width 25 +extern const unsigned char question[] PROGMEM; + +#define bang_height 30 +#define bang_width 30 +extern const unsigned char bang[] PROGMEM; + +#define haha_height 30 +#define haha_width 30 +extern const unsigned char haha[] PROGMEM; + +#define wave_icon_height 30 +#define wave_icon_width 30 +extern const unsigned char wave_icon[] PROGMEM; + +#define cowboy_height 30 +#define cowboy_width 30 +extern const unsigned char cowboy[] PROGMEM; + +#define deadmau5_height 30 +#define deadmau5_width 60 +extern const unsigned char deadmau5[] PROGMEM; + +#define sun_height 30 +#define sun_width 30 +extern const unsigned char sun[] PROGMEM; + +#define rain_height 30 +#define rain_width 30 +extern const unsigned char rain[] PROGMEM; + +#define cloud_height 30 +#define cloud_width 30 +extern const unsigned char cloud[] PROGMEM; + +#define fog_height 25 +#define fog_width 25 +extern const unsigned char fog[] PROGMEM; + +#define devil_height 30 +#define devil_width 30 +extern const unsigned char devil[] PROGMEM; + +#define heart_height 30 +#define heart_width 30 +extern const unsigned char heart[] PROGMEM; + +#define poo_height 30 +#define poo_width 30 +extern const unsigned char poo[] PROGMEM; + +#define bell_icon_width 30 +#define bell_icon_height 30 +extern const unsigned char bell_icon[] PROGMEM; +#endif // EXCLUDE_EMOJI + +} // namespace graphics diff --git a/src/graphics/images.h b/src/graphics/images.h index 069839a16..e9c2f00ea 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -6,7 +6,12 @@ const uint8_t SATELLITE_IMAGE[] PROGMEM = {0x00, 0x08, 0x00, 0x1C, 0x00, 0x0E, 0 0xF8, 0x00, 0xF0, 0x01, 0xE0, 0x03, 0xC8, 0x01, 0x9C, 0x54, 0x0E, 0x52, 0x07, 0x48, 0x02, 0x26, 0x00, 0x10, 0x00, 0x0E}; -const uint8_t imgSatellite[] PROGMEM = {0x70, 0x71, 0x22, 0xFA, 0xFA, 0x22, 0x71, 0x70}; +#define imgSatellite_width 8 +#define imgSatellite_height 8 +const uint8_t imgSatellite[] PROGMEM = { + 0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b11011011, 0b11111111, 0b11011011, 0b00011000, +}; + const uint8_t imgUSB[] PROGMEM = {0x60, 0x60, 0x30, 0x18, 0x18, 0x18, 0x24, 0x42, 0x42, 0x42, 0x42, 0x7E, 0x24, 0x24, 0x24, 0x3C}; const uint8_t imgPower[] PROGMEM = {0x40, 0x40, 0x40, 0x58, 0x48, 0x08, 0x08, 0x08, 0x1C, 0x22, 0x22, 0x41, 0x7F, 0x22, 0x22, 0x22}; @@ -14,11 +19,12 @@ const uint8_t imgUser[] PROGMEM = {0x3C, 0x42, 0x99, 0xA5, 0xA5, 0x99, 0x42, 0x3 const uint8_t imgPositionEmpty[] PROGMEM = {0x20, 0x30, 0x28, 0x24, 0x42, 0xFF}; const uint8_t imgPositionSolid[] PROGMEM = {0x20, 0x30, 0x38, 0x3C, 0x7E, 0xFF}; -#if defined(DISPLAY_CLOCK_FRAME) const uint8_t bluetoothConnectedIcon[36] PROGMEM = {0xfe, 0x01, 0xff, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x00, 0xe3, 0x1f, 0xf3, 0x3f, 0x33, 0x30, 0x33, 0x33, 0x33, 0x33, 0x03, 0x33, 0xff, 0x33, 0xfe, 0x31, 0x00, 0x30, 0x30, 0x30, 0x30, 0x30, 0xf0, 0x3f, 0xe0, 0x1f}; -#endif + +// This image definition is here instead of images.h because it's modified dynamically by the drawBattery function +static uint8_t imgBattery[16] = {0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xE7, 0x3C}; #if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || ARCH_PORTDUINO) && \ @@ -37,181 +43,248 @@ const uint8_t imgQuestion[] PROGMEM = {0xbf, 0x41, 0xc0, 0x8b, 0xdb, 0x70, 0xa1, const uint8_t imgSF[] PROGMEM = {0xd2, 0xb7, 0xad, 0xbb, 0x92, 0x01, 0xfd, 0xfd, 0x15, 0x85, 0xf5}; #endif -#ifndef EXCLUDE_EMOJI -#define thumbs_height 25 -#define thumbs_width 25 -static unsigned char thumbup[] PROGMEM = { - 0x00, 0x1C, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x80, 0x09, 0x00, 0x00, - 0xC0, 0x08, 0x00, 0x00, 0x40, 0x08, 0x00, 0x00, 0x20, 0x04, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, - 0x0C, 0xCE, 0x7F, 0x00, 0x04, 0x20, 0x80, 0x00, 0x02, 0x20, 0x80, 0x00, 0x02, 0x60, 0xC0, 0x00, 0x01, 0xF8, 0xFF, 0x01, - 0x01, 0x08, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01, 0x01, 0xF8, 0xFF, 0x00, 0x01, 0x10, 0x80, 0x00, 0x01, 0x18, 0x80, 0x00, - 0x02, 0x30, 0xC0, 0x00, 0x06, 0xE0, 0x3F, 0x00, 0x0C, 0x20, 0x30, 0x00, 0x38, 0x20, 0x10, 0x00, 0xE0, 0xCF, 0x1F, 0x00, +// === Horizontal battery === +// Basic battery design and all related pieces +const unsigned char batteryBitmap_h[] PROGMEM = { + 0b11111110, 0b00000000, 0b11110000, 0b00000111, 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b00000001, 0b00000000, + 0b00000000, 0b00001000, 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b00000001, 0b00000000, 0b00000000, 0b00001000, + 0b00000001, 0b00000000, 0b00000000, 0b00011000, 0b00000001, 0b00000000, 0b00000000, 0b00011000, 0b00000001, 0b00000000, + 0b00000000, 0b00011000, 0b00000001, 0b00000000, 0b00000000, 0b00011000, 0b00000001, 0b00000000, 0b00000000, 0b00011000, + 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b00000001, 0b00000000, + 0b00000000, 0b00001000, 0b00000001, 0b00000000, 0b00000000, 0b00001000, 0b11111110, 0b00000000, 0b11110000, 0b00000111}; + +// This is the left and right bars for the fill in +const unsigned char batteryBitmap_sidegaps_h[] PROGMEM = { + 0b11111111, 0b00001111, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, + 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, + 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b11111111, 0b00001111}; + +// Lightning Bolt +const unsigned char lightning_bolt_h[] PROGMEM = { + 0b00000000, 0b00000000, 0b00100000, 0b00000000, 0b00110000, 0b00000000, 0b00111000, 0b00000000, 0b00111100, + 0b00000000, 0b00011110, 0b00000000, 0b11111111, 0b00000000, 0b01111000, 0b00000000, 0b00111100, 0b00000000, + 0b00011100, 0b00000000, 0b00001100, 0b00000000, 0b00000100, 0b00000000, 0b00000000, 0b00000000}; + +// === Vertical battery === +// Basic battery design and all related pieces +const unsigned char batteryBitmap_v[] PROGMEM = {0b00011100, 0b00111110, 0b01000001, 0b01000001, 0b00000000, 0b00000000, + 0b00000000, 0b01000001, 0b01000001, 0b01000001, 0b00111110}; +// This is the left and right bars for the fill in +const unsigned char batteryBitmap_sidegaps_v[] PROGMEM = {0b10000010, 0b10000010, 0b10000010}; +// Lightning Bolt +const unsigned char lightning_bolt_v[] PROGMEM = {0b00000100, 0b00000110, 0b00011111, 0b00001100, 0b00000100}; + +#define mail_width 10 +#define mail_height 7 +static const unsigned char mail[] PROGMEM = { + 0b11111111, 0b00, // Top line + 0b10000001, 0b00, // Edges + 0b11000011, 0b00, // Diagonals start + 0b10100101, 0b00, // Inner M part + 0b10011001, 0b00, // Inner M part + 0b10000001, 0b00, // Edges + 0b11111111, 0b00 // Bottom line }; -static unsigned char thumbdown[] PROGMEM = { - 0xE0, 0xCF, 0x1F, 0x00, 0x38, 0x20, 0x10, 0x00, 0x0C, 0x20, 0x30, 0x00, 0x06, 0xE0, 0x3F, 0x00, 0x02, 0x30, 0xC0, 0x00, - 0x01, 0x18, 0x80, 0x00, 0x01, 0x10, 0x80, 0x00, 0x01, 0xF8, 0xFF, 0x00, 0x01, 0x08, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01, - 0x01, 0xF8, 0xFF, 0x01, 0x02, 0x60, 0xC0, 0x00, 0x02, 0x20, 0x80, 0x00, 0x04, 0x20, 0x80, 0x00, 0x0C, 0xCE, 0x7F, 0x00, - 0x18, 0x02, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x20, 0x04, 0x00, 0x00, 0x40, 0x08, 0x00, 0x00, 0xC0, 0x08, 0x00, 0x00, - 0x80, 0x09, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x00, +// 📬 Mail / Message +const uint8_t icon_mail[] PROGMEM = { + 0b11111111, // ████████ top border + 0b10000001, // █ █ sides + 0b11000011, // ██ ██ diagonal + 0b10100101, // █ █ █ █ inner M + 0b10011001, // █ ██ █ inner M + 0b10000001, // █ █ sides + 0b10000001, // █ █ sides + 0b11111111 // ████████ bottom }; -#define smiley_height 30 -#define smiley_width 30 -static unsigned char smiley[] PROGMEM = { - 0x00, 0xfe, 0x0f, 0x00, 0x80, 0x01, 0x30, 0x00, 0x40, 0x00, 0xc0, 0x00, 0x20, 0x00, 0x00, 0x01, 0x10, 0x00, 0x00, 0x02, - 0x08, 0x00, 0x00, 0x04, 0x04, 0x00, 0x00, 0x08, 0x04, 0x00, 0x00, 0x10, 0x02, 0x0e, 0x0e, 0x10, 0x02, 0x09, 0x12, 0x10, - 0x01, 0x09, 0x12, 0x20, 0x01, 0x0f, 0x1e, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, - 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x81, 0x00, 0x20, 0x20, - 0x82, 0x00, 0x20, 0x10, 0x02, 0x01, 0x10, 0x10, 0x04, 0x02, 0x08, 0x08, 0x04, 0xfc, 0x07, 0x08, 0x08, 0x00, 0x00, 0x04, - 0x10, 0x00, 0x00, 0x02, 0x20, 0x00, 0x00, 0x01, 0x40, 0x00, 0xc0, 0x00, 0x80, 0x01, 0x30, 0x00, 0x00, 0xfe, 0x0f, 0x00}; - -#define question_height 25 -#define question_width 25 -static unsigned char question[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x80, 0xFF, 0x01, 0x00, 0xC0, 0xFF, 0x07, 0x00, 0xE0, 0xFF, 0x07, 0x00, - 0xE0, 0xC3, 0x0F, 0x00, 0xF0, 0x81, 0x0F, 0x00, 0xF0, 0x01, 0x0F, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x80, 0x0F, 0x00, - 0x00, 0xC0, 0x0F, 0x00, 0x00, 0xE0, 0x07, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0xF8, 0x01, 0x00, 0x00, 0x7C, 0x00, 0x00, - 0x00, 0x3C, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +// 📍 GPS Screen / Location Pin +const unsigned char icon_compass[] PROGMEM = { + 0x3C, // Row 0: ..####.. + 0x52, // Row 1: .#..#.#. + 0x91, // Row 2: #...#..# + 0x91, // Row 3: #...#..# + 0x91, // Row 4: #...#..# + 0x81, // Row 5: #......# + 0x42, // Row 6: .#....#. + 0x3C // Row 7: ..####.. }; -#define bang_height 30 -#define bang_width 30 -static unsigned char bang[] PROGMEM = { - 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x0F, 0xFC, 0x3F, 0xFF, 0x07, 0xF8, 0x3F, 0xFF, 0x07, 0xF8, 0x3F, - 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, - 0xFE, 0x03, 0xF0, 0x1F, 0xFE, 0x03, 0xF0, 0x1F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, - 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x03, 0xF0, 0x0F, 0xFC, 0x01, 0xE0, 0x0F, 0xFC, 0x01, 0xE0, 0x0F, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0xC0, 0x03, 0xFC, 0x03, 0xF0, 0x0F, 0xFE, 0x03, 0xF0, 0x1F, - 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFE, 0x07, 0xF8, 0x1F, 0xFC, 0x03, 0xF0, 0x0F, 0xF8, 0x01, 0xE0, 0x07, +const uint8_t icon_radio[] PROGMEM = { + 0x0F, // Row 0: ####.... + 0x10, // Row 1: ....#... + 0x27, // Row 2: ###..#.. + 0x48, // Row 3: ...#..#. + 0x93, // Row 4: ##..#..# + 0xA4, // Row 5: ..#..#.# + 0xA8, // Row 6: ...#.#.# + 0xA9 // Row 7: #..#.#.# }; -#define haha_height 30 -#define haha_width 30 -static unsigned char haha[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x01, 0x00, - 0x00, 0xFC, 0x0F, 0x00, 0x00, 0x1F, 0x3E, 0x00, 0x80, 0x03, 0x70, 0x00, 0xC0, 0x01, 0xE0, 0x00, 0xC0, 0x00, 0xC2, 0x00, - 0x60, 0x00, 0x03, 0x00, 0x60, 0x00, 0xC1, 0x1F, 0x60, 0x80, 0x8F, 0x31, 0x30, 0x0E, 0x80, 0x31, 0x30, 0x10, 0x30, 0x1F, - 0x30, 0x08, 0x58, 0x00, 0x30, 0x04, 0x6C, 0x03, 0x60, 0x00, 0xF3, 0x01, 0x60, 0xC0, 0xFC, 0x01, 0x80, 0x38, 0xBF, 0x01, - 0xE0, 0xC5, 0xDF, 0x00, 0xB0, 0xF9, 0xEF, 0x00, 0x30, 0xF1, 0x73, 0x00, 0xB0, 0x1D, 0x3E, 0x00, 0xF0, 0xFD, 0x0F, 0x00, - 0xE0, 0xE0, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +// 🪙 Memory Icon +const uint8_t icon_memory[] PROGMEM = { + 0x24, // Row 0: ..#..#.. + 0x3C, // Row 1: ..####.. + 0xC3, // Row 2: ##....## + 0x5A, // Row 3: .#.##.#. + 0x5A, // Row 4: .#.##.#. + 0xC3, // Row 5: ##....## + 0x3C, // Row 6: ..####.. + 0x24 // Row 7: ..#..#.. }; -#define wave_icon_height 30 -#define wave_icon_width 30 -static unsigned char wave_icon[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0xC0, 0x00, - 0x00, 0x0C, 0x9C, 0x01, 0x80, 0x17, 0x20, 0x01, 0x80, 0x26, 0x46, 0x02, 0x80, 0x44, 0x88, 0x02, 0xC0, 0x89, 0x8A, 0x02, - 0x40, 0x93, 0x8B, 0x02, 0x40, 0x26, 0x13, 0x00, 0x80, 0x44, 0x16, 0x00, 0xC0, 0x89, 0x24, 0x00, 0x40, 0x93, 0x60, 0x00, - 0x40, 0x26, 0x40, 0x00, 0x80, 0x0C, 0x80, 0x00, 0x00, 0x09, 0x80, 0x00, 0x00, 0x02, 0x80, 0x00, 0x40, 0x06, 0x80, 0x00, - 0x50, 0x0C, 0x80, 0x00, 0x50, 0x08, 0x40, 0x00, 0x90, 0x10, 0x20, 0x00, 0xB0, 0x21, 0x10, 0x00, 0x20, 0x47, 0x18, 0x00, - 0x40, 0x80, 0x0F, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +// 🌐 Wi-Fi +const uint8_t icon_wifi[] PROGMEM = {0b00000000, 0b00011000, 0b00111100, 0b01111110, + 0b11011011, 0b00011000, 0b00011000, 0b00000000}; + +const uint8_t icon_nodes[] PROGMEM = { + 0xF9, // Row 0 #..####### + 0x00, // Row 1 + 0xF9, // Row 2 #..####### + 0x00, // Row 3 + 0xF9, // Row 4 #..####### + 0x00, // Row 5 + 0xF9, // Row 6 #..####### + 0x00 // Row 7 }; -#define cowboy_height 30 -#define cowboy_width 30 -static unsigned char cowboy[] PROGMEM = { - 0x00, 0xF0, 0x03, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0x00, 0xFE, 0x1F, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x3C, 0xFE, 0x1F, 0x0F, - 0xFE, 0xFE, 0xDF, 0x1F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, - 0x3E, 0xC0, 0x00, 0x1F, 0x1E, 0x00, 0x00, 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x08, 0x0E, 0x1C, 0x04, 0x00, 0x0E, 0x1C, 0x00, - 0x04, 0x0E, 0x1C, 0x08, 0x04, 0x0E, 0x1C, 0x08, 0x04, 0x04, 0x08, 0x08, 0x04, 0x00, 0x00, 0x08, 0x04, 0x00, 0x00, 0x08, - 0x8C, 0x07, 0x70, 0x0C, 0x88, 0xFC, 0x4F, 0x04, 0x88, 0x01, 0x40, 0x04, 0x90, 0xFF, 0x7F, 0x02, 0x30, 0x03, 0x30, 0x03, - 0x60, 0x0E, 0x9C, 0x01, 0xC0, 0xF8, 0xC7, 0x00, 0x80, 0x01, 0x60, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, 0xF8, 0x07, 0x00, +// ➤ Chevron Triangle Arrow Icon (8x8) +const uint8_t icon_list[] PROGMEM = { + 0x10, // Row 0: ...#.... + 0x10, // Row 1: ...#.... + 0x38, // Row 2: ..###... + 0x38, // Row 3: ..###... + 0x7C, // Row 4: .#####.. + 0x6C, // Row 5: .##.##.. + 0xC6, // Row 6: ##...##. + 0x82 // Row 7: #.....#. }; -#define deadmau5_height 30 -#define deadmau5_width 60 -static unsigned char deadmau5[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x07, 0x00, - 0x00, 0xFC, 0x03, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x80, 0xFF, 0x0F, 0x00, 0xC0, 0xFF, 0xFF, 0x00, 0xE0, 0xFF, 0x3F, 0x00, - 0xE0, 0xFF, 0xFF, 0x01, 0xF0, 0xFF, 0x7F, 0x00, 0xF0, 0xFF, 0xFF, 0x03, 0xF8, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x07, - 0xFC, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x0F, 0xFC, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0x0F, 0xFE, 0xFF, 0xFF, 0x00, - 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xE0, 0xFF, 0x3F, 0xFC, - 0x0F, 0xFF, 0x7F, 0x00, 0xC0, 0xFF, 0x1F, 0xF8, 0x0F, 0xFC, 0x3F, 0x00, 0x80, 0xFF, 0x0F, 0xF8, 0x1F, 0xFC, 0x1F, 0x00, - 0x00, 0xFF, 0x0F, 0xFC, 0x3F, 0xFC, 0x0F, 0x00, 0x00, 0xF8, 0x1F, 0xFF, 0xFF, 0xFE, 0x01, 0x00, 0x00, 0x00, 0xFC, 0xFF, - 0xFF, 0x07, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x18, 0x00, - 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xC0, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x80, 0x07, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +// 📶 Signal Bars Icon (left to right, small to large with spacing) +const uint8_t icon_signal[] PROGMEM = { + 0b00000000, // ░░░░░░░ + 0b10000000, // ░░░░░░░ + 0b10100000, // ░░░░█░█ + 0b10100000, // ░░░░█░█ + 0b10101000, // ░░█░█░█ + 0b10101000, // ░░█░█░█ + 0b10101010, // █░█░█░█ + 0b11111111 // ███████ }; -#define sun_width 30 -#define sun_height 30 -static unsigned char sun[] PROGMEM = { - 0x00, 0xC0, 0x00, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x30, 0xC0, 0x00, 0x03, - 0x70, 0x00, 0x80, 0x03, 0xF0, 0x00, 0xC0, 0x03, 0xF0, 0xF8, 0xC7, 0x03, 0xE0, 0xFC, 0xCF, 0x01, 0x00, 0xFE, 0x1F, 0x00, - 0x00, 0xFF, 0x3F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x8E, 0xFF, 0x7F, 0x1C, 0x9F, 0xFF, 0x7F, 0x3E, - 0x9F, 0xFF, 0x7F, 0x3E, 0x8E, 0xFF, 0x7F, 0x1C, 0x80, 0xFF, 0x7F, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0xFF, 0x3F, 0x00, - 0x00, 0xFE, 0x1F, 0x00, 0x00, 0xFC, 0x0F, 0x00, 0xC0, 0xF9, 0xE7, 0x00, 0xE0, 0x01, 0xE0, 0x01, 0xF0, 0x01, 0xE0, 0x03, - 0xF0, 0xC0, 0xC0, 0x03, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0xC0, 0x00, 0x00, +// ↔️ Distance / Measurement Icon (double-ended arrow) +const uint8_t icon_distance[] PROGMEM = { + 0b00000000, // ░░░░░░░░ + 0b10000001, // █░░░░░█ arrowheads + 0b01000010, // ░█░░░█░ + 0b00100100, // ░░█░█░░ + 0b00011000, // ░░░██░░ center + 0b00100100, // ░░█░█░░ + 0b01000010, // ░█░░░█░ + 0b10000001 // █░░░░░█ }; -#define rain_width 30 -#define rain_height 30 -static unsigned char rain[] PROGMEM = { - 0xC0, 0x0F, 0xC0, 0x00, 0x40, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x03, 0x38, 0x00, - 0x00, 0x0E, 0x0C, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, 0x10, 0x03, 0x00, 0x00, 0x30, 0x01, 0x00, 0x00, 0x20, - 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x03, 0x00, 0x00, 0x30, 0x02, 0x00, - 0x00, 0x10, 0x06, 0x00, 0x00, 0x08, 0xFC, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x01, 0x80, 0x00, 0x01, 0x00, - 0xC0, 0xC0, 0x81, 0x03, 0xA0, 0x60, 0xC1, 0x03, 0x90, 0x20, 0x41, 0x01, 0xF0, 0xE0, 0xC0, 0x01, 0x60, 0x4C, - 0x98, 0x00, 0x00, 0x0E, 0x1C, 0x00, 0x00, 0x0B, 0x12, 0x00, 0x00, 0x09, 0x1A, 0x00, 0x00, 0x06, 0x0E, 0x00, +// ⚠️ Error / Fault +const uint8_t icon_error[] PROGMEM = { + 0b00011000, // ░░░██░░░ + 0b00011000, // ░░░██░░░ + 0b00011000, // ░░░██░░░ + 0b00011000, // ░░░██░░░ + 0b00000000, // ░░░░░░░░ + 0b00011000, // ░░░██░░░ + 0b00000000, // ░░░░░░░░ + 0b00000000 // ░░░░░░░░ }; -#define cloud_height 30 -#define cloud_width 30 -static unsigned char cloud[] PROGMEM = { - 0x00, 0x80, 0x07, 0x00, 0x00, 0xE0, 0x1F, 0x00, 0x00, 0x70, 0x30, 0x00, 0x00, 0x10, 0x60, 0x00, 0x80, 0x1F, 0x40, 0x00, - 0xC0, 0x0F, 0xC0, 0x00, 0xC0, 0x00, 0x80, 0x00, 0x60, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x00, 0x20, 0x00, 0x80, 0x01, - 0x20, 0x00, 0x00, 0x07, 0x38, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x08, 0x06, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00, 0x10, - 0x02, 0x00, 0x00, 0x30, 0x03, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, - 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x20, 0x01, 0x00, 0x00, 0x30, 0x03, 0x00, 0x00, 0x10, - 0x02, 0x00, 0x00, 0x10, 0x06, 0x00, 0x00, 0x18, 0x0C, 0x00, 0x00, 0x0C, 0xFC, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x03, +// 🏠 Optimized Home Icon (8x8) +const uint8_t icon_home[] PROGMEM = { + 0b00011000, // ██ + 0b00111100, // ████ + 0b01111110, // ██████ + 0b11111111, // ███████ + 0b11000011, // ██ ██ + 0b11011011, // ██ ██ ██ + 0b11011011, // ██ ██ ██ + 0b11111111 // ███████ }; -#define fog_height 25 -#define fog_width 25 -static unsigned char fog[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x78, 0x00, 0x3C, 0x00, 0xFE, 0x01, 0xFF, 0x00, 0x87, 0xC7, 0xC3, 0x01, 0x03, 0xFE, 0x80, 0x01, - 0x00, 0x38, 0x00, 0x00, 0xFC, 0x00, 0x7E, 0x00, 0xFF, 0x83, 0xFF, 0x01, 0x03, 0xFF, 0x81, 0x01, 0x00, 0x7C, 0x00, 0x00, - 0xF8, 0x00, 0x3E, 0x00, 0xFE, 0x01, 0xFF, 0x00, 0x87, 0xC7, 0xC3, 0x01, 0x03, 0xFE, 0x80, 0x01, 0x00, 0x38, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +// 🔧 Generic module (gear-like shape) +const uint8_t icon_module[] PROGMEM = { + 0b00011000, // ░░░██░░░ + 0b00111100, // ░░████░░ + 0b01111110, // ░██████░ + 0b11011011, // ██░██░██ + 0b11011011, // ██░██░██ + 0b01111110, // ░██████░ + 0b00111100, // ░░████░░ + 0b00011000 // ░░░██░░░ }; -#define devil_height 30 -#define devil_width 30 -static unsigned char devil[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x10, 0x03, 0xC0, 0x01, 0x38, 0x07, 0x7C, 0x0F, 0x38, 0x1F, 0x03, 0x30, 0x1E, - 0xFE, 0x01, 0xE0, 0x1F, 0x7E, 0x00, 0x80, 0x1F, 0x3C, 0x00, 0x00, 0x0F, 0x1C, 0x00, 0x00, 0x0E, 0x18, 0x00, 0x00, 0x06, - 0x08, 0x00, 0x00, 0x04, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x0E, 0x1C, 0x0C, - 0x0C, 0x18, 0x06, 0x0C, 0x0C, 0x1C, 0x06, 0x0C, 0x0C, 0x1C, 0x0E, 0x0C, 0x0C, 0x1C, 0x0E, 0x0C, 0x0C, 0x0C, 0x06, 0x0C, - 0x08, 0x00, 0x00, 0x06, 0x18, 0x02, 0x10, 0x06, 0x10, 0x0C, 0x0C, 0x03, 0x30, 0xF8, 0x07, 0x03, 0x60, 0xE0, 0x80, 0x01, - 0xC0, 0x00, 0xC0, 0x00, 0x80, 0x01, 0x70, 0x00, 0x00, 0x06, 0x1C, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, +#define mute_symbol_width 8 +#define mute_symbol_height 8 +const uint8_t mute_symbol[] PROGMEM = { + 0b00011001, // █ + 0b00100110, // █ + 0b00100100, // ████ + 0b01001010, // █ █ █ + 0b01010010, // █ █ █ + 0b01100010, // ████████ + 0b11111111, // █ █ + 0b10011000, // █ }; -#define heart_height 30 -#define heart_width 30 -static unsigned char heart[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0xC0, 0x03, 0xF0, 0x00, 0xF8, 0x0F, 0xFC, 0x07, 0xFC, 0x1F, 0x06, 0x0E, 0xFE, 0x3F, 0x03, 0x18, - 0xFE, 0xFF, 0x7F, 0x10, 0xFF, 0xFF, 0xFF, 0x31, 0xFF, 0xFF, 0xFF, 0x33, 0xFF, 0xFF, 0xFF, 0x37, 0xFF, 0xFF, 0xFF, 0x37, - 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, 0xFE, 0xFF, 0xFF, 0x1F, 0xFE, 0xFF, 0xFF, 0x1F, - 0xFC, 0xFF, 0xFF, 0x0F, 0xFC, 0xFF, 0xFF, 0x0F, 0xF8, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, 0xFF, 0x03, 0xF0, 0xFF, 0xFF, 0x03, - 0xE0, 0xFF, 0xFF, 0x01, 0xC0, 0xFF, 0xFF, 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00, 0xFF, 0x3F, 0x00, 0x00, 0xFE, 0x1F, 0x00, - 0x00, 0xFC, 0x0F, 0x00, 0x00, 0xF8, 0x07, 0x00, 0x00, 0xF0, 0x03, 0x00, 0x00, 0xE0, 0x01, 0x00, 0x00, 0x80, 0x00, 0x00, +#define mute_symbol_big_width 16 +#define mute_symbol_big_height 16 +const uint8_t mute_symbol_big[] PROGMEM = {0b00000001, 0b00000000, 0b11000010, 0b00000011, 0b00110100, 0b00001100, 0b00011000, + 0b00001000, 0b00011000, 0b00010000, 0b00101000, 0b00010000, 0b01001000, 0b00010000, + 0b10001000, 0b00010000, 0b00001000, 0b00010001, 0b00001000, 0b00010010, 0b00001000, + 0b00010100, 0b00000100, 0b00101000, 0b11111100, 0b00111111, 0b01000000, 0b00100010, + 0b10000000, 0b01000001, 0b00000000, 0b10000000}; + +// Bell icon for Alert Message +#define bell_alert_width 8 +#define bell_alert_height 8 +const unsigned char bell_alert[] PROGMEM = {0b00011000, 0b00100100, 0b00100100, 0b01000010, + 0b01000010, 0b01000010, 0b11111111, 0b00011000}; + +#define key_symbol_width 8 +#define key_symbol_height 8 +const uint8_t key_symbol[] PROGMEM = {0b00000000, 0b00000000, 0b00000110, 0b11111001, + 0b10101001, 0b10000110, 0b00000000, 0b00000000}; + +#define placeholder_width 8 +#define placeholder_height 8 +const uint8_t placeholder[] PROGMEM = {0b11111111, 0b11111111, 0b11111111, 0b11111111, + 0b11111111, 0b11111111, 0b11111111, 0b11111111}; + +#define icon_node_width 8 +#define icon_node_height 8 +static const uint8_t icon_node[] PROGMEM = { + 0x10, // # + 0x10, // # ← antenna + 0x10, // # + 0xFE, // ####### ← device top + 0x82, // # # + 0xAA, // # # # # ← body with pattern + 0x92, // # # # + 0xFE // ####### ← device base }; -#define poo_width 30 -#define poo_height 30 -static unsigned char poo[] PROGMEM = { - 0x00, 0x1C, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0xEC, 0x01, 0x00, 0x00, 0x8C, 0x07, 0x00, 0x00, 0x0C, 0x06, 0x00, - 0x00, 0x24, 0x0C, 0x00, 0x00, 0x34, 0x08, 0x00, 0x00, 0x1F, 0x08, 0x00, 0xC0, 0x0F, 0x08, 0x00, 0xC0, 0x00, 0x3C, 0x00, - 0x60, 0x00, 0x7C, 0x00, 0x60, 0x00, 0xC6, 0x00, 0x20, 0x00, 0xCB, 0x00, 0xA0, 0xC7, 0xFF, 0x00, 0xE0, 0x7F, 0xF7, 0x00, - 0xF0, 0x18, 0xE3, 0x03, 0x78, 0x18, 0x41, 0x03, 0x6C, 0x9B, 0x5D, 0x06, 0x64, 0x9B, 0x5D, 0x04, 0x44, 0x1A, 0x41, 0x04, - 0x4C, 0xD8, 0x63, 0x06, 0xF8, 0xFC, 0x36, 0x06, 0xFE, 0x0F, 0x9C, 0x1F, 0x07, 0x03, 0xC0, 0x30, 0x03, 0x00, 0x78, 0x20, - 0x01, 0x00, 0x1F, 0x20, 0x03, 0xE0, 0x03, 0x20, 0x07, 0x7E, 0x04, 0x30, 0xFE, 0x0F, 0xFC, 0x1F, 0xF0, 0x00, 0xF0, 0x0F, -}; -#endif +#define bluetoothdisabled_width 8 +#define bluetoothdisabled_height 8 +const uint8_t bluetoothdisabled[] PROGMEM = {0b11101100, 0b01010100, 0b01001100, 0b01010100, + 0b01001100, 0b00000000, 0b00000000, 0b00000000}; + +#define smallbulletpoint_width 8 +#define smallbulletpoint_height 8 +const uint8_t smallbulletpoint[] PROGMEM = {0b00000011, 0b00000011, 0b00000000, 0b00000000, + 0b00000000, 0b00000000, 0b00000000, 0b00000000}; + +// Clock +#define icon_clock_width 8 +#define icon_clock_height 8 +const uint8_t icon_clock[] PROGMEM = {0b00111100, 0b01000010, 0b10000101, 0b10101001, + 0b10010001, 0b10000001, 0b01000010, 0b00111100}; #include "img/icon.xbm" +static_assert(sizeof(icon_bits) >= 0, "Silence unused variable warning"); \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index 109f75df5..f07645989 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -3,6 +3,7 @@ #include "./Events.h" #include "RTC.h" +#include "buzz.h" #include "modules/AdminModule.h" #include "modules/ExternalNotificationModule.h" #include "modules/TextMessageModule.h" @@ -38,6 +39,9 @@ void InkHUD::Events::begin() void InkHUD::Events::onButtonShort() { + // Audio feedback (via buzzer) + // Short low tone + playBoop(); // Cancel any beeping, buzzing, blinking // Some button handling suppressed if we are dismissing an external notification (see below) bool dismissedExt = dismissExternalNotification(); @@ -60,6 +64,10 @@ void InkHUD::Events::onButtonShort() void InkHUD::Events::onButtonLong() { + // Audio feedback (via buzzer) + // Low tone, longer than playBoop + playBeep(); + // Check which system applet wants to handle the button press (if any) SystemApplet *consumer = nullptr; for (SystemApplet *sa : inkhud->systemApplets) { @@ -107,6 +115,10 @@ int InkHUD::Events::beforeDeepSleep(void *unused) inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, false); delay(1000); // Cooldown, before potentially yanking display power + // InkHUD shutdown complete + // Firmware shutdown continues for several seconds more; flash write still pending + playShutdownMelody(); + return 0; // We agree: deep sleep now } diff --git a/src/graphics/niche/InkHUD/PlatformioConfig.ini b/src/graphics/niche/InkHUD/PlatformioConfig.ini index cab0ea7bc..e5a0e67df 100644 --- a/src/graphics/niche/InkHUD/PlatformioConfig.ini +++ b/src/graphics/niche/InkHUD/PlatformioConfig.ini @@ -6,6 +6,7 @@ build_flags = -D MESHTASTIC_INCLUDE_NICHE_GRAPHICS ; Use NicheGraphics -D MESHTASTIC_INCLUDE_INKHUD ; Use InkHUD (a NicheGraphics UI) -D MESHTASTIC_EXCLUDE_SCREEN ; Suppress default Screen class + -D MESHTASTIC_EXCLUDE_INPUTBROKER ; Suppress default input handling -D HAS_BUTTON=0 ; Suppress default ButtonThread lib_deps = https://github.com/ZinggJM/GFX_Root#2.0.0 ; Used by InkHUD as a "slimmer" version of AdafruitGFX \ No newline at end of file diff --git a/src/input/ButtonThread.cpp b/src/input/ButtonThread.cpp new file mode 100644 index 000000000..bc75e0a54 --- /dev/null +++ b/src/input/ButtonThread.cpp @@ -0,0 +1,318 @@ +#include "ButtonThread.h" +#include "meshUtils.h" + +#include "configuration.h" +#if !MESHTASTIC_EXCLUDE_GPS +#include "GPS.h" +#endif +#include "MeshService.h" +#include "RadioLibInterface.h" +#include "buzz.h" +#include "input/InputBroker.h" +#include "main.h" +#include "modules/CannedMessageModule.h" +#include "modules/ExternalNotificationModule.h" +#include "power.h" +#include "sleep.h" +#ifdef ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" +#endif + +using namespace concurrency; + +#if HAS_BUTTON +#endif +ButtonThread::ButtonThread(const char *name) : OSThread(name) +{ + _originName = name; +} + +bool ButtonThread::initButton(uint8_t pinNumber, bool activeLow, bool activePullup, uint32_t pullupSense, voidFuncPtr intRoutine, + input_broker_event singlePress, input_broker_event longPress, uint16_t longPressTime, + input_broker_event doublePress, input_broker_event longLongPress, uint16_t longLongPressTime, + input_broker_event triplePress, input_broker_event shortLong, bool touchQuirk) +{ + if (inputBroker) + inputBroker->registerSource(this); + _longPressTime = longPressTime; + _longLongPressTime = longLongPressTime; + _pinNum = pinNumber; + _activeLow = activeLow; + _touchQuirk = touchQuirk; + _intRoutine = intRoutine; + _longLongPress = longLongPress; + + userButton = OneButton(pinNumber, activeLow, activePullup); + + if (pullupSense != 0) { + pinMode(pinNumber, pullupSense); + } + + _singlePress = singlePress; + userButton.attachClick( + [](void *callerThread) -> void { + ButtonThread *thread = (ButtonThread *)callerThread; + thread->btnEvent = BUTTON_EVENT_PRESSED; + }, + this); + + if (longPress != INPUT_BROKER_NONE) { + _longPress = longPress; + userButton.attachLongPressStart( + [](void *callerThread) -> void { + ButtonThread *thread = (ButtonThread *)callerThread; + if (millis() > 30000) // hold off 30s after boot + thread->btnEvent = BUTTON_EVENT_LONG_PRESSED; + }, + this); + userButton.attachLongPressStop( + [](void *callerThread) -> void { + ButtonThread *thread = (ButtonThread *)callerThread; + if (millis() > 30000) // hold off 30s after boot + thread->btnEvent = BUTTON_EVENT_LONG_RELEASED; + }, + this); + } + + if (doublePress != INPUT_BROKER_NONE) { + _doublePress = doublePress; + userButton.attachDoubleClick( + [](void *callerThread) -> void { + ButtonThread *thread = (ButtonThread *)callerThread; + thread->btnEvent = BUTTON_EVENT_DOUBLE_PRESSED; + }, + this); + } + + if (triplePress != INPUT_BROKER_NONE) { + _triplePress = triplePress; + userButton.attachMultiClick( + [](void *callerThread) -> void { + ButtonThread *thread = (ButtonThread *)callerThread; + thread->storeClickCount(); + thread->btnEvent = BUTTON_EVENT_MULTI_PRESSED; + }, + this); + } + if (shortLong != INPUT_BROKER_NONE) { + _shortLong = shortLong; + } + + userButton.setDebounceMs(1); + userButton.setPressMs(_longPressTime); + + if (screen) { + userButton.setClickMs(20); + } else { + userButton.setClickMs(BUTTON_CLICK_MS); + } + attachButtonInterrupts(); +#ifdef ARCH_ESP32 + // Register callbacks for before and after lightsleep + // Used to detach and reattach interrupts + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif + return true; +} + +int32_t ButtonThread::runOnce() +{ + // If the button is pressed we suppress CPU sleep until release + canSleep = true; // Assume we should not keep the board awake + + // Check for combination timeout + if (waitingForLongPress && (millis() - shortPressTime) > BUTTON_COMBO_TIMEOUT_MS) { + waitingForLongPress = false; + } + + userButton.tick(); + canSleep &= userButton.isIdle(); + + // Check if we should play lead-up sound during long press + // Play lead-up when button has been held for BUTTON_LEADUP_MS but before long press triggers + bool buttonCurrentlyPressed = isButtonPressed(_pinNum); + + // Detect start of button press + if (buttonCurrentlyPressed && !buttonWasPressed) { + buttonPressStartTime = millis(); + leadUpPlayed = false; + leadUpSequenceActive = false; + resetLeadUpSequence(); + } + + // Progressive lead-up sound system + if (buttonCurrentlyPressed && (millis() - buttonPressStartTime) >= BUTTON_LEADUP_MS && + (millis() - buttonPressStartTime) < _longLongPressTime) { + + // Start the progressive sequence if not already active + if (!leadUpSequenceActive) { + leadUpSequenceActive = true; + lastLeadUpNoteTime = millis(); + playNextLeadUpNote(); // Play the first note immediately + } + // Continue playing notes at intervals + else if ((millis() - lastLeadUpNoteTime) >= 400) { // 400ms interval between notes + if (playNextLeadUpNote()) { + lastLeadUpNoteTime = millis(); + } + } + } + + // Reset when button is released + if (!buttonCurrentlyPressed && buttonWasPressed) { + leadUpPlayed = false; + leadUpSequenceActive = false; + resetLeadUpSequence(); + } + + buttonWasPressed = buttonCurrentlyPressed; + + // new behavior + if (btnEvent != BUTTON_EVENT_NONE) { + InputEvent evt; + evt.source = _originName; + evt.kbchar = 0; + evt.touchX = 0; + evt.touchY = 0; + switch (btnEvent) { + case BUTTON_EVENT_PRESSED: { + // Forward single press to InputBroker (but NOT as DOWN/SELECT, just forward a "button press" event) + evt.inputEvent = _singlePress; + // evt.kbchar = _singlePress; // todo: fix this. Some events are kb characters rather than event types + this->notifyObservers(&evt); + + // Start tracking for potential combination + waitingForLongPress = true; + shortPressTime = millis(); + + break; + } + case BUTTON_EVENT_LONG_PRESSED: { + // Ignore if: TX in progress + // Uncommon T-Echo hardware bug, LoRa TX triggers touch button + if (_touchQuirk && RadioLibInterface::instance && RadioLibInterface::instance->isSending()) + break; + + // Check if this is part of a short-press + long-press combination + if (_shortLong != INPUT_BROKER_NONE && waitingForLongPress && + (millis() - shortPressTime) <= BUTTON_COMBO_TIMEOUT_MS) { + evt.inputEvent = _shortLong; + // evt.kbchar = _shortLong; + this->notifyObservers(&evt); + // Play the combination tune + playComboTune(); + + break; + } + + // Forward long press to InputBroker (but NOT as DOWN/SELECT, just forward a "button long press" event) + evt.inputEvent = _longPress; + this->notifyObservers(&evt); + + // Reset combination tracking + waitingForLongPress = false; + + break; + } + + case BUTTON_EVENT_DOUBLE_PRESSED: { // not wired in if screen detected + LOG_INFO("Double press!"); + + // Reset combination tracking + waitingForLongPress = false; + + evt.inputEvent = _doublePress; + // evt.kbchar = _doublePress; + this->notifyObservers(&evt); + playComboTune(); + + break; + } + + case BUTTON_EVENT_MULTI_PRESSED: { // not wired in when screen is present + LOG_INFO("Mulitipress! %hux", multipressClickCount); + + // Reset combination tracking + waitingForLongPress = false; + + switch (multipressClickCount) { + case 3: + evt.inputEvent = _triplePress; + // evt.kbchar = _triplePress; + this->notifyObservers(&evt); + playComboTune(); + break; + + // No valid multipress action + default: + break; + } // end switch: click count + + break; + } // end multipress event + + // Do actual shutdown when button released, otherwise the button release + // may wake the board immediatedly. + case BUTTON_EVENT_LONG_RELEASED: { + + LOG_INFO("LONG PRESS RELEASE"); + if (_longLongPress != INPUT_BROKER_NONE && (millis() - buttonPressStartTime) >= _longLongPressTime) { + evt.inputEvent = _longLongPress; + this->notifyObservers(&evt); + } + // Reset combination tracking + waitingForLongPress = false; + + break; + } + } + } + btnEvent = BUTTON_EVENT_NONE; + return 50; +} + +/* + * Attach (or re-attach) hardware interrupts for buttons + * Public method. Used outside class when waking from MCU sleep + */ +void ButtonThread::attachButtonInterrupts() +{ + // Interrupt for user button, during normal use. Improves responsiveness. + attachInterrupt(_pinNum, _intRoutine, CHANGE); +} + +/* + * Detach the "normal" button interrupts. + * Public method. Used before attaching a "wake-on-button" interrupt for MCU sleep + */ +void ButtonThread::detachButtonInterrupts() +{ + detachInterrupt(_pinNum); +} + +#ifdef ARCH_ESP32 + +// Detach our class' interrupts before lightsleep +// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press +int ButtonThread::beforeLightSleep(void *unused) +{ + detachButtonInterrupts(); + return 0; // Indicates success +} + +// Reconfigure our interrupts +// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep +int ButtonThread::afterLightSleep(esp_sleep_wakeup_cause_t cause) +{ + attachButtonInterrupts(); + return 0; // Indicates success +} + +#endif + +// Non-static method, runs during callback. Grabs info while still valid +void ButtonThread::storeClickCount() +{ + multipressClickCount = userButton.getNumberClicks(); +} \ No newline at end of file diff --git a/src/input/ButtonThread.h b/src/input/ButtonThread.h new file mode 100644 index 000000000..033f92b8b --- /dev/null +++ b/src/input/ButtonThread.h @@ -0,0 +1,113 @@ +#pragma once + +#include "InputBroker.h" +#include "OneButton.h" +#include "concurrency/OSThread.h" +#include "configuration.h" + +typedef void (*voidFuncPtr)(void); + +#ifndef BUTTON_CLICK_MS +#define BUTTON_CLICK_MS 250 +#endif + +#ifndef BUTTON_TOUCH_MS +#define BUTTON_TOUCH_MS 400 +#endif + +#ifndef BUTTON_COMBO_TIMEOUT_MS +#define BUTTON_COMBO_TIMEOUT_MS 1000 // 1 second to complete the combination -- tap faster +#endif + +#ifndef BUTTON_LEADUP_MS +#define BUTTON_LEADUP_MS 2200 // Play lead-up sound after 2.5 seconds of holding +#endif + +class ButtonThread : public Observable, public concurrency::OSThread +{ + public: + const char *_originName; + static const uint32_t c_holdOffTime = 30000; // hold off 30s after boot + bool initButton(uint8_t pinNumber, bool activeLow, bool activePullup, uint32_t pullupSense, voidFuncPtr intRoutine, + input_broker_event singlePress, input_broker_event longPress = INPUT_BROKER_NONE, + uint16_t longPressTime = 500, input_broker_event doublePress = INPUT_BROKER_NONE, + input_broker_event longLongPress = INPUT_BROKER_NONE, uint16_t longLongPressTime = 5000, + input_broker_event triplePress = INPUT_BROKER_NONE, input_broker_event shortLong = INPUT_BROKER_NONE, + bool touchQuirk = false); + + enum ButtonEventType { + BUTTON_EVENT_NONE, + BUTTON_EVENT_PRESSED, + BUTTON_EVENT_PRESSED_SCREEN, + BUTTON_EVENT_DOUBLE_PRESSED, + BUTTON_EVENT_MULTI_PRESSED, + BUTTON_EVENT_LONG_PRESSED, + BUTTON_EVENT_LONG_RELEASED, + BUTTON_EVENT_TOUCH_LONG_PRESSED, + BUTTON_EVENT_COMBO_SHORT_LONG, + }; + + ButtonThread(const char *name); + int32_t runOnce() override; + OneButton userButton; + void attachButtonInterrupts(); + void detachButtonInterrupts(); + void storeClickCount(); + bool isButtonPressed(int buttonPin) + { + if (_activeLow) + return !digitalRead(buttonPin); // Active low: pressed = LOW + else + return digitalRead(buttonPin); // Most buttons are active low by default + } + + // Disconnect and reconnect interrupts for light sleep +#ifdef ARCH_ESP32 + int beforeLightSleep(void *unused); + int afterLightSleep(esp_sleep_wakeup_cause_t cause); +#endif + private: + input_broker_event _singlePress = INPUT_BROKER_NONE; + input_broker_event _longPress = INPUT_BROKER_NONE; + input_broker_event _longLongPress = INPUT_BROKER_NONE; + + input_broker_event _doublePress = INPUT_BROKER_NONE; + input_broker_event _triplePress = INPUT_BROKER_NONE; + input_broker_event _shortLong = INPUT_BROKER_NONE; + + voidFuncPtr _intRoutine = nullptr; + uint16_t _longPressTime = 500; + uint16_t _longLongPressTime = 5000; + int _pinNum = 0; + bool _activeLow = true; + bool _touchQuirk = false; + + uint32_t buttonPressStartTime = 0; + bool buttonWasPressed = false; + +#ifdef ARCH_ESP32 + // Get notified when lightsleep begins and ends + CallbackObserver lsObserver = + CallbackObserver(this, &ButtonThread::beforeLightSleep); + CallbackObserver lsEndObserver = + CallbackObserver(this, &ButtonThread::afterLightSleep); +#endif + + volatile ButtonEventType btnEvent = BUTTON_EVENT_NONE; + + // Store click count during callback, for later use + volatile int multipressClickCount = 0; + + // Combination tracking state + bool waitingForLongPress = false; + uint32_t shortPressTime = 0; + + // Long press lead-up tracking + bool leadUpPlayed = false; + uint32_t lastLeadUpNoteTime = 0; + bool leadUpSequenceActive = false; + + static void wakeOnIrq(int irq, int mode); +}; + +extern ButtonThread *buttonThread; diff --git a/src/input/ExpressLRSFiveWay.cpp b/src/input/ExpressLRSFiveWay.cpp index 56413bd55..1981a45d4 100644 --- a/src/input/ExpressLRSFiveWay.cpp +++ b/src/input/ExpressLRSFiveWay.cpp @@ -146,31 +146,31 @@ void ExpressLRSFiveWay::determineAction(KeyType key, PressLength length) { switch (key) { case LEFT: - if (inCannedMessageMenu()) // If in canned message menu - sendKey(CANCEL); // exit the menu (press imaginary cancel key) + if (inCannedMessageMenu()) // If in canned message menu + sendKey(INPUT_BROKER_CANCEL); // exit the menu (press imaginary cancel key) else - sendKey(LEFT); + sendKey(INPUT_BROKER_LEFT); break; case RIGHT: - if (inCannedMessageMenu()) // If in canned message menu: - sendKey(CANCEL); // exit the menu (press imaginary cancel key) + if (inCannedMessageMenu()) // If in canned message menu: + sendKey(INPUT_BROKER_CANCEL); // exit the menu (press imaginary cancel key) else - sendKey(RIGHT); + sendKey(INPUT_BROKER_RIGHT); break; case UP: if (length == LONG) toggleGPS(); else - sendKey(UP); + sendKey(INPUT_BROKER_UP); break; case DOWN: if (length == LONG) sendAdhocPing(); else - sendKey(DOWN); + sendKey(INPUT_BROKER_DOWN); break; case OK: @@ -186,7 +186,7 @@ void ExpressLRSFiveWay::determineAction(KeyType key, PressLength length) } // Feed input to the canned messages module -void ExpressLRSFiveWay::sendKey(KeyType key) +void ExpressLRSFiveWay::sendKey(input_broker_event key) { InputEvent e; e.source = inputSourceName; @@ -243,15 +243,9 @@ void ExpressLRSFiveWay::shutdown() shutdownAtMsec = millis() + 3000; } -// Emulate user button, or canned message SELECT -// This is necessary as canned message module doesn't translate SELECT to user button presses if the module is disabled -// Contained as one method for easier remapping of buttons by user void ExpressLRSFiveWay::click() { - if (!moduleConfig.canned_message.enabled) - powerFSM.trigger(EVENT_PRESS); - else - sendKey(OK); + sendKey(INPUT_BROKER_SELECT); } ExpressLRSFiveWay *expressLRSFiveWayInput = nullptr; diff --git a/src/input/ExpressLRSFiveWay.h b/src/input/ExpressLRSFiveWay.h index c53aa9c09..7c7f210f8 100644 --- a/src/input/ExpressLRSFiveWay.h +++ b/src/input/ExpressLRSFiveWay.h @@ -40,13 +40,13 @@ class ExpressLRSFiveWay : public Observable, public concurre // This merged an enum used by the ExpressLRS code, with meshtastic canned message values // Key names are kept simple, to allow user customizaton typedef enum { - UP = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP, - DOWN = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN, - LEFT = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT, - RIGHT = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT, - OK = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT, - CANCEL = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL, - NO_PRESS = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE + UP = INPUT_BROKER_UP, + DOWN = INPUT_BROKER_DOWN, + LEFT = INPUT_BROKER_LEFT, + RIGHT = INPUT_BROKER_RIGHT, + OK = INPUT_BROKER_SELECT, + CANCEL = INPUT_BROKER_CANCEL, + NO_PRESS = INPUT_BROKER_NONE } KeyType; typedef enum { SHORT, LONG } PressLength; @@ -63,7 +63,7 @@ class ExpressLRSFiveWay : public Observable, public concurre // Meshtastic code void determineAction(KeyType key, PressLength length); - void sendKey(KeyType key); + void sendKey(input_broker_event key); inline bool inCannedMessageMenu() { return cannedMessageModule->shouldDraw(); } int32_t runOnce() override; diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index cb73e32ba..ef6d8df91 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -12,7 +12,7 @@ void InputBroker::registerSource(Observable *source) int InputBroker::handleInputEvent(const InputEvent *event) { - powerFSM.trigger(EVENT_INPUT); + powerFSM.trigger(EVENT_INPUT); // todo: not every input should wake, like long hold release this->notifyObservers(event); return 0; } \ No newline at end of file diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index db7524bb0..4487fa662 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -1,29 +1,40 @@ #pragma once #include "Observer.h" -#define ANYKEY 0xFF -#define MATRIXKEY 0xFE +enum input_broker_event { + INPUT_BROKER_NONE = 0, + INPUT_BROKER_SELECT = 10, + INPUT_BROKER_UP = 17, + INPUT_BROKER_DOWN = 18, + INPUT_BROKER_LEFT = 19, + INPUT_BROKER_RIGHT = 20, + INPUT_BROKER_CANCEL = 24, + INPUT_BROKER_BACK = 27, + INPUT_BROKER_USER_PRESS, + INPUT_BROKER_ALT_PRESS, + INPUT_BROKER_ALT_LONG, + INPUT_BROKER_SHUTDOWN = 0x9b, + INPUT_BROKER_GPS_TOGGLE = 0x9e, + INPUT_BROKER_SEND_PING = 0xaf, + INPUT_BROKER_MATRIXKEY = 0xFE, + INPUT_BROKER_ANYKEY = 0xff + +}; #define INPUT_BROKER_MSG_BRIGHTNESS_UP 0x11 #define INPUT_BROKER_MSG_BRIGHTNESS_DOWN 0x12 #define INPUT_BROKER_MSG_REBOOT 0x90 -#define INPUT_BROKER_MSG_SHUTDOWN 0x9b -#define INPUT_BROKER_MSG_GPS_TOGGLE 0x9e #define INPUT_BROKER_MSG_MUTE_TOGGLE 0xac -#define INPUT_BROKER_MSG_SEND_PING 0xaf -#define INPUT_BROKER_MSG_DISMISS_FRAME 0x8b -#define INPUT_BROKER_MSG_LEFT 0xb4 -#define INPUT_BROKER_MSG_UP 0xb5 -#define INPUT_BROKER_MSG_DOWN 0xb6 -#define INPUT_BROKER_MSG_RIGHT 0xb7 #define INPUT_BROKER_MSG_FN_SYMBOL_ON 0xf1 #define INPUT_BROKER_MSG_FN_SYMBOL_OFF 0xf2 #define INPUT_BROKER_MSG_BLUETOOTH_TOGGLE 0xAA +#define INPUT_BROKER_MSG_TAB 0x09 +#define INPUT_BROKER_MSG_EMOTE_LIST 0x8F typedef struct _InputEvent { const char *source; - char inputEvent; - char kbchar; + input_broker_event inputEvent; + unsigned char kbchar; uint16_t touchX; uint16_t touchY; } InputEvent; @@ -35,6 +46,7 @@ class InputBroker : public Observable public: InputBroker(); void registerSource(Observable *source); + void injectInputEvent(const InputEvent *event) { handleInputEvent(event); } protected: int handleInputEvent(const InputEvent *event); diff --git a/src/input/LinuxInput.cpp b/src/input/LinuxInput.cpp index 57a87b0ef..90f06ecc9 100644 --- a/src/input/LinuxInput.cpp +++ b/src/input/LinuxInput.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +51,7 @@ int32_t LinuxInput::runOnce() perror("unable to epoll add"); return disable(); } + kb_found = true; // This is the first time the OSThread library has called this function, so do port setup firstTime = 0; } @@ -72,7 +74,7 @@ int32_t LinuxInput::runOnce() assert(rd > ((signed int)sizeof(struct input_event))); for (int j = 0; j < rd / ((signed int)sizeof(struct input_event)); j++) { InputEvent e; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; e.kbchar = 0; unsigned int type, code; @@ -131,36 +133,36 @@ int32_t LinuxInput::runOnce() mod = 0x08; break; case KEY_ESC: // ESC - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL; + e.inputEvent = INPUT_BROKER_CANCEL; break; case KEY_BACK: // Back - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK; + e.inputEvent = INPUT_BROKER_BACK; // e.kbchar = key; break; case KEY_UP: // Up - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP; + e.inputEvent = INPUT_BROKER_UP; break; case KEY_DOWN: // Down - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN; + e.inputEvent = INPUT_BROKER_DOWN; break; case KEY_LEFT: // Left - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT; + e.inputEvent = INPUT_BROKER_LEFT; break; - e.kbchar = INPUT_BROKER_MSG_LEFT; + e.kbchar = INPUT_BROKER_LEFT; case KEY_RIGHT: // Right - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT; + e.inputEvent = INPUT_BROKER_RIGHT; break; - e.kbchar = INPUT_BROKER_MSG_RIGHT; + e.kbchar = 0; case KEY_ENTER: // Enter - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; + e.inputEvent = INPUT_BROKER_SELECT; break; case KEY_POWER: system("poweroff"); break; default: // all other keys if (keymap[code]) { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = keymap[code]; } break; @@ -173,8 +175,8 @@ int32_t LinuxInput::runOnce() } report[0] = modifiers; } - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { - if (e.inputEvent == ANYKEY && (modifiers && 0x22)) + if (e.inputEvent != INPUT_BROKER_NONE) { + if (e.inputEvent == INPUT_BROKER_ANYKEY && (modifiers && 0x22)) e.kbchar = uppers[e.kbchar]; // doesn't get punctuation. Meh. this->notifyObservers(&e); } diff --git a/src/input/RotaryEncoderInterruptBase.cpp b/src/input/RotaryEncoderInterruptBase.cpp index 785d98ebe..0557bc180 100644 --- a/src/input/RotaryEncoderInterruptBase.cpp +++ b/src/input/RotaryEncoderInterruptBase.cpp @@ -7,7 +7,8 @@ RotaryEncoderInterruptBase::RotaryEncoderInterruptBase(const char *name) : concu } void RotaryEncoderInterruptBase::init( - uint8_t pinA, uint8_t pinB, uint8_t pinPress, char eventCw, char eventCcw, char eventPressed, + uint8_t pinA, uint8_t pinB, uint8_t pinPress, input_broker_event eventCw, input_broker_event eventCcw, + input_broker_event eventPressed, // std::function onIntA, std::function onIntB, std::function onIntPress) : void (*onIntA)(), void (*onIntB)(), void (*onIntPress)()) { @@ -34,7 +35,7 @@ void RotaryEncoderInterruptBase::init( int32_t RotaryEncoderInterruptBase::runOnce() { InputEvent e; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; if (this->action == ROTARY_ACTION_PRESSED) { @@ -48,7 +49,7 @@ int32_t RotaryEncoderInterruptBase::runOnce() e.inputEvent = this->_eventCcw; } - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE) { this->notifyObservers(&e); } diff --git a/src/input/RotaryEncoderInterruptBase.h b/src/input/RotaryEncoderInterruptBase.h index 9bcf25a69..9bdab4730 100644 --- a/src/input/RotaryEncoderInterruptBase.h +++ b/src/input/RotaryEncoderInterruptBase.h @@ -12,7 +12,8 @@ class RotaryEncoderInterruptBase : public Observable, public { public: explicit RotaryEncoderInterruptBase(const char *name); - void init(uint8_t pinA, uint8_t pinB, uint8_t pinPress, char eventCw, char eventCcw, char eventPressed, + void init(uint8_t pinA, uint8_t pinB, uint8_t pinPress, input_broker_event eventCw, input_broker_event eventCcw, + input_broker_event eventPressed, // std::function onIntA, std::function onIntB, std::function onIntPress); void (*onIntA)(), void (*onIntB)(), void (*onIntPress)()); void intPressHandler(); @@ -34,8 +35,8 @@ class RotaryEncoderInterruptBase : public Observable, public private: uint8_t _pinA = 0; uint8_t _pinB = 0; - char _eventCw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; - char _eventCcw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; - char _eventPressed = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + input_broker_event _eventCw = INPUT_BROKER_NONE; + input_broker_event _eventCcw = INPUT_BROKER_NONE; + input_broker_event _eventPressed = INPUT_BROKER_NONE; const char *_originName; }; diff --git a/src/input/RotaryEncoderInterruptImpl1.cpp b/src/input/RotaryEncoderInterruptImpl1.cpp index 7e79289e5..4f19c8b0b 100644 --- a/src/input/RotaryEncoderInterruptImpl1.cpp +++ b/src/input/RotaryEncoderInterruptImpl1.cpp @@ -16,9 +16,9 @@ bool RotaryEncoderInterruptImpl1::init() uint8_t pinA = moduleConfig.canned_message.inputbroker_pin_a; uint8_t pinB = moduleConfig.canned_message.inputbroker_pin_b; uint8_t pinPress = moduleConfig.canned_message.inputbroker_pin_press; - char eventCw = static_cast(moduleConfig.canned_message.inputbroker_event_cw); - char eventCcw = static_cast(moduleConfig.canned_message.inputbroker_event_ccw); - char eventPressed = static_cast(moduleConfig.canned_message.inputbroker_event_press); + input_broker_event eventCw = static_cast(moduleConfig.canned_message.inputbroker_event_cw); + input_broker_event eventCcw = static_cast(moduleConfig.canned_message.inputbroker_event_ccw); + input_broker_event eventPressed = static_cast(moduleConfig.canned_message.inputbroker_event_press); // moduleConfig.canned_message.ext_notification_module_output RotaryEncoderInterruptBase::init(pinA, pinB, pinPress, eventCw, eventCcw, eventPressed, diff --git a/src/input/ScanAndSelect.cpp b/src/input/ScanAndSelect.cpp deleted file mode 100644 index 1262f99b4..000000000 --- a/src/input/ScanAndSelect.cpp +++ /dev/null @@ -1,230 +0,0 @@ -#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" -#include -#ifdef ARCH_PORTDUINO // Only to check for pin conflict with user button -#include "platform/portduino/PortduinoGlue.h" -#endif - -// 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; - - // Determine which pin to use for the single scan-and-select button - // User can specify this by setting any of the inputbroker pins - // If all values are zero, we'll assume the user *does* want GPIO0 - 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 - pin = 0; // GPIO 0 then - - // Short circuit: if selected pin conficts with the user button -#if defined(ARCH_PORTDUINO) - int pinUserButton = 0; - if (settingsMap.count(user) != 0) { - pinUserButton = settingsMap[user]; - } -#elif defined(USERPREFS_BUTTON_PIN) - int pinUserButton = config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN; -#elif defined(BUTTON_PIN) - int pinUserButton = config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN; -#else - int pinUserButton = config.device.button_gpio; -#endif - if (pin == pinUserButton) { - LOG_ERROR("ScanAndSelect conflict with user button"); - return false; - } - - // 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", 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 (!Throttle::isWithinTimespanMs(alertingSinceMs, durationAlertMs)) { - alertingNoMessage = false; - screen->endAlert(); - } - } - - // If: Button is pressed - if (digitalRead(pin) == LOW) { - // New press - if (!held) { - downSinceMs = now; - } - - // Existing press - else { - // Longer than shortpress window - // Long press not yet fired (prevent repeat firing while held) - if (!longPressFired && !Throttle::isWithinTimespanMs(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 within shortpress window - // - longer than durationShortPress (debounce) - // - shorter than durationLongPress - if (!Throttle::isWithinTimespanMs(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 deleted file mode 100644 index 0b3e2716e..000000000 --- a/src/input/ScanAndSelect.h +++ /dev/null @@ -1,50 +0,0 @@ -/* - 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/input/SerialKeyboard.cpp b/src/input/SerialKeyboard.cpp index 8d0730418..63501bda5 100644 --- a/src/input/SerialKeyboard.cpp +++ b/src/input/SerialKeyboard.cpp @@ -30,7 +30,7 @@ SerialKeyboard::SerialKeyboard(const char *name) : concurrency::OSThread(name) void SerialKeyboard::erase() { InputEvent e; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK; + e.inputEvent = INPUT_BROKER_BACK; e.kbchar = 0x08; e.source = this->_originName; this->notifyObservers(&e); @@ -81,18 +81,18 @@ int32_t SerialKeyboard::runOnce() if (keys < prevKeys) { // a new key has been pressed (and not released), doesn't works for multiple presses at once but // shouldn't be a limitation InputEvent e; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; // SELECT OR SEND OR CANCEL EVENT if (!(shiftRegister2 & (1 << 3))) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP; + e.inputEvent = INPUT_BROKER_UP; } else if (!(shiftRegister2 & (1 << 2))) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT; - e.kbchar = INPUT_BROKER_MSG_RIGHT; + e.inputEvent = INPUT_BROKER_RIGHT; + e.kbchar = 0; } else if (!(shiftRegister2 & (1 << 1))) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; + e.inputEvent = INPUT_BROKER_SELECT; } else if (!(shiftRegister2 & (1 << 0))) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL; + e.inputEvent = INPUT_BROKER_CANCEL; } // TEXT INPUT EVENT @@ -120,10 +120,10 @@ int32_t SerialKeyboard::runOnce() // BACKSPACE or TAB else if (!(shiftRegister1 & (1 << 7))) { if (shift == 0 || shift == 2) { // BACKSPACE - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK; + e.inputEvent = INPUT_BROKER_BACK; e.kbchar = 0x08; } else { // shift = 1 => TAB - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = 0x09; } } @@ -146,7 +146,7 @@ int32_t SerialKeyboard::runOnce() if (keyPressed == lastKeyPressed && millis() - lastPressTime < 500) { erase(); } - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = char(KeyMap[shift][quickPress][keyPressed]); } else { // then it's shift shift += 1; @@ -159,7 +159,7 @@ int32_t SerialKeyboard::runOnce() keyPressed = 13; } - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE) { this->notifyObservers(&e); } } diff --git a/src/input/TCA8418Keyboard.cpp b/src/input/TCA8418Keyboard.cpp index 21cd7b2d5..d99379b23 100644 --- a/src/input/TCA8418Keyboard.cpp +++ b/src/input/TCA8418Keyboard.cpp @@ -147,7 +147,6 @@ TCA8418Keyboard::TCA8418Keyboard() : m_wire(nullptr), m_addr(0), readCallback(nu { state = Init; last_key = -1; - next_key = -1; should_backspace = false; last_tap = 0L; char_idx = 0; diff --git a/src/input/TCA8418Keyboard.h b/src/input/TCA8418Keyboard.h index c7f3c1f28..5c53452a4 100644 --- a/src/input/TCA8418Keyboard.h +++ b/src/input/TCA8418Keyboard.h @@ -21,7 +21,6 @@ class TCA8418Keyboard KeyState state; int8_t last_key; - int8_t next_key; bool should_backspace; uint32_t last_tap; uint8_t char_idx; diff --git a/src/input/TouchScreenBase.cpp b/src/input/TouchScreenBase.cpp index d2f7b54f8..c2755980e 100644 --- a/src/input/TouchScreenBase.cpp +++ b/src/input/TouchScreenBase.cpp @@ -43,6 +43,8 @@ int32_t TouchScreenBase::runOnce() // process touch events int16_t x, y; bool touched = getTouch(x, y); + if (x < 0 || y < 0) // T-deck can emit phantom touch events with a negative value when turing off the screen + touched = false; if (touched) { this->setInterval(20); _last_x = x; @@ -93,8 +95,6 @@ int32_t TouchScreenBase::runOnce() if (duration > 0 && duration < TIME_LONG_PRESS) { if (_tapped) { _tapped = false; - e.touchEvent = static_cast(TOUCH_ACTION_DOUBLE_TAP); - LOG_DEBUG("action DOUBLE TAP(%d/%d)", x, y); } else { _tapped = true; } @@ -124,7 +124,7 @@ int32_t TouchScreenBase::runOnce() } #else // fire TAP event when no 2nd tap occured within time - if (_tapped && (time_t(millis()) - _start) > TIME_LONG_PRESS - 50) { + if (_tapped) { _tapped = false; e.touchEvent = static_cast(TOUCH_ACTION_TAP); LOG_DEBUG("action TAP(%d/%d)", _last_x, _last_y); diff --git a/src/input/TouchScreenBase.h b/src/input/TouchScreenBase.h index 0b2002551..90314cf02 100644 --- a/src/input/TouchScreenBase.h +++ b/src/input/TouchScreenBase.h @@ -28,7 +28,6 @@ class TouchScreenBase : public Observable, public concurrenc TOUCH_ACTION_LEFT, TOUCH_ACTION_RIGHT, TOUCH_ACTION_TAP, - TOUCH_ACTION_DOUBLE_TAP, TOUCH_ACTION_LONG_PRESS }; diff --git a/src/input/TouchScreenImpl1.cpp b/src/input/TouchScreenImpl1.cpp index 20196278d..cea47faeb 100644 --- a/src/input/TouchScreenImpl1.cpp +++ b/src/input/TouchScreenImpl1.cpp @@ -49,41 +49,33 @@ void TouchScreenImpl1::onEvent(const TouchEvent &event) { InputEvent e; e.source = event.source; - + e.kbchar = 0; e.touchX = event.x; e.touchY = event.y; switch (event.touchEvent) { case TOUCH_ACTION_LEFT: { - e.inputEvent = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT); + e.inputEvent = INPUT_BROKER_LEFT; break; } case TOUCH_ACTION_RIGHT: { - e.inputEvent = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT); + e.inputEvent = INPUT_BROKER_RIGHT; break; } case TOUCH_ACTION_UP: { - e.inputEvent = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP); + e.inputEvent = INPUT_BROKER_UP; break; } case TOUCH_ACTION_DOWN: { - e.inputEvent = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN); - break; - } - case TOUCH_ACTION_DOUBLE_TAP: { - e.inputEvent = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT); + e.inputEvent = INPUT_BROKER_DOWN; break; } case TOUCH_ACTION_LONG_PRESS: { - e.inputEvent = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL); + e.inputEvent = INPUT_BROKER_SELECT; break; } case TOUCH_ACTION_TAP: { - if (moduleConfig.external_notification.enabled && (externalNotificationModule->nagCycleCutoff != UINT32_MAX)) { - externalNotificationModule->stopNow(); - } else { - powerFSM.trigger(EVENT_INPUT); - } + e.inputEvent = INPUT_BROKER_USER_PRESS; break; } default: diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index e35da3622..41045ee8e 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -4,9 +4,9 @@ TrackballInterruptBase::TrackballInterruptBase(const char *name) : concurrency::OSThread(name), _originName(name) {} void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, - char eventDown, char eventUp, char eventLeft, char eventRight, char eventPressed, - void (*onIntDown)(), void (*onIntUp)(), void (*onIntLeft)(), void (*onIntRight)(), - void (*onIntPress)()) + input_broker_event eventDown, input_broker_event eventUp, input_broker_event eventLeft, + input_broker_event eventRight, input_broker_event eventPressed, void (*onIntDown)(), + void (*onIntUp)(), void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()) { this->_pinDown = pinDown; this->_pinUp = pinUp; @@ -18,17 +18,26 @@ void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLef this->_eventRight = eventRight; this->_eventPressed = eventPressed; - pinMode(pinPress, INPUT_PULLUP); - pinMode(this->_pinDown, INPUT_PULLUP); - pinMode(this->_pinUp, INPUT_PULLUP); - pinMode(this->_pinLeft, INPUT_PULLUP); - pinMode(this->_pinRight, INPUT_PULLUP); - - attachInterrupt(pinPress, onIntPress, RISING); - attachInterrupt(this->_pinDown, onIntDown, RISING); - attachInterrupt(this->_pinUp, onIntUp, RISING); - attachInterrupt(this->_pinLeft, onIntLeft, RISING); - attachInterrupt(this->_pinRight, onIntRight, RISING); + if (pinPress != 255) { + pinMode(pinPress, INPUT_PULLUP); + attachInterrupt(pinPress, onIntPress, RISING); + } + if (this->_pinDown != 255) { + pinMode(this->_pinDown, INPUT_PULLUP); + attachInterrupt(this->_pinDown, onIntDown, RISING); + } + if (this->_pinUp != 255) { + pinMode(this->_pinUp, INPUT_PULLUP); + attachInterrupt(this->_pinUp, onIntUp, RISING); + } + if (this->_pinLeft != 255) { + pinMode(this->_pinLeft, INPUT_PULLUP); + attachInterrupt(this->_pinLeft, onIntLeft, RISING); + } + if (this->_pinRight != 255) { + pinMode(this->_pinRight, INPUT_PULLUP); + attachInterrupt(this->_pinRight, onIntRight, RISING); + } LOG_DEBUG("Trackball GPIO initialized (%d, %d, %d, %d, %d)", this->_pinUp, this->_pinDown, this->_pinLeft, this->_pinRight, pinPress); @@ -39,8 +48,25 @@ void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLef int32_t TrackballInterruptBase::runOnce() { InputEvent e; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; - + e.inputEvent = INPUT_BROKER_NONE; +#if defined(T_DECK) // T-deck gets a super-simple debounce on trackball + if (this->action == TB_ACTION_PRESSED) { + // LOG_DEBUG("Trackball event Press"); + e.inputEvent = this->_eventPressed; + } else if (this->action == TB_ACTION_UP && lastEvent == TB_ACTION_UP) { + // LOG_DEBUG("Trackball event UP"); + e.inputEvent = this->_eventUp; + } else if (this->action == TB_ACTION_DOWN && lastEvent == TB_ACTION_DOWN) { + // LOG_DEBUG("Trackball event DOWN"); + e.inputEvent = this->_eventDown; + } else if (this->action == TB_ACTION_LEFT && lastEvent == TB_ACTION_LEFT) { + // LOG_DEBUG("Trackball event LEFT"); + e.inputEvent = this->_eventLeft; + } else if (this->action == TB_ACTION_RIGHT && lastEvent == TB_ACTION_RIGHT) { + // LOG_DEBUG("Trackball event RIGHT"); + e.inputEvent = this->_eventRight; + } +#else if (this->action == TB_ACTION_PRESSED) { // LOG_DEBUG("Trackball event Press"); e.inputEvent = this->_eventPressed; @@ -57,13 +83,14 @@ int32_t TrackballInterruptBase::runOnce() // LOG_DEBUG("Trackball event RIGHT"); e.inputEvent = this->_eventRight; } +#endif - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE) { e.source = this->_originName; e.kbchar = 0x00; this->notifyObservers(&e); } - + lastEvent = action; this->action = TB_ACTION_NONE; return 100; diff --git a/src/input/TrackballInterruptBase.h b/src/input/TrackballInterruptBase.h index e7fc99f54..dac31a137 100644 --- a/src/input/TrackballInterruptBase.h +++ b/src/input/TrackballInterruptBase.h @@ -7,9 +7,10 @@ class TrackballInterruptBase : public Observable, public con { public: explicit TrackballInterruptBase(const char *name); - void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, char eventDown, char eventUp, - char eventLeft, char eventRight, char eventPressed, void (*onIntDown)(), void (*onIntUp)(), void (*onIntLeft)(), - void (*onIntRight)(), void (*onIntPress)()); + void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, input_broker_event eventDown, + input_broker_event eventUp, input_broker_event eventLeft, input_broker_event eventRight, + input_broker_event eventPressed, void (*onIntDown)(), void (*onIntUp)(), void (*onIntLeft)(), void (*onIntRight)(), + void (*onIntPress)()); void intPressHandler(); void intDownHandler(); void intUpHandler(); @@ -35,10 +36,11 @@ class TrackballInterruptBase : public Observable, public con uint8_t _pinUp = 0; uint8_t _pinLeft = 0; uint8_t _pinRight = 0; - char _eventDown = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; - char _eventUp = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; - char _eventLeft = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; - char _eventRight = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; - char _eventPressed = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + input_broker_event _eventDown = INPUT_BROKER_NONE; + input_broker_event _eventUp = INPUT_BROKER_NONE; + input_broker_event _eventLeft = INPUT_BROKER_NONE; + input_broker_event _eventRight = INPUT_BROKER_NONE; + input_broker_event _eventPressed = INPUT_BROKER_NONE; const char *_originName; + TrackballInterruptBaseActionType lastEvent = TB_ACTION_NONE; }; diff --git a/src/input/TrackballInterruptImpl1.cpp b/src/input/TrackballInterruptImpl1.cpp index 0a73b83b6..c6d21ac2b 100644 --- a/src/input/TrackballInterruptImpl1.cpp +++ b/src/input/TrackballInterruptImpl1.cpp @@ -6,30 +6,19 @@ TrackballInterruptImpl1 *trackballInterruptImpl1; TrackballInterruptImpl1::TrackballInterruptImpl1() : TrackballInterruptBase("trackball1") {} -void TrackballInterruptImpl1::init() +void TrackballInterruptImpl1::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress) { -#if !HAS_TRACKBALL - // Input device is disabled. - return; -#else - uint8_t pinUp = TB_UP; - uint8_t pinDown = TB_DOWN; - uint8_t pinLeft = TB_LEFT; - uint8_t pinRight = TB_RIGHT; - uint8_t pinPress = TB_PRESS; - - char eventDown = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN); - char eventUp = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP); - char eventLeft = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT); - char eventRight = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT); - char eventPressed = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT); + input_broker_event eventDown = INPUT_BROKER_DOWN; + input_broker_event eventUp = INPUT_BROKER_UP; + input_broker_event eventLeft = INPUT_BROKER_LEFT; + input_broker_event eventRight = INPUT_BROKER_RIGHT; + input_broker_event eventPressed = INPUT_BROKER_SELECT; TrackballInterruptBase::init(pinDown, pinUp, pinLeft, pinRight, pinPress, eventDown, eventUp, eventLeft, eventRight, eventPressed, TrackballInterruptImpl1::handleIntDown, TrackballInterruptImpl1::handleIntUp, TrackballInterruptImpl1::handleIntLeft, TrackballInterruptImpl1::handleIntRight, TrackballInterruptImpl1::handleIntPressed); inputBroker->registerSource(this); -#endif } void TrackballInterruptImpl1::handleIntDown() diff --git a/src/input/TrackballInterruptImpl1.h b/src/input/TrackballInterruptImpl1.h index 36efac6a6..4683efa41 100644 --- a/src/input/TrackballInterruptImpl1.h +++ b/src/input/TrackballInterruptImpl1.h @@ -5,7 +5,7 @@ class TrackballInterruptImpl1 : public TrackballInterruptBase { public: TrackballInterruptImpl1(); - void init(); + void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress); static void handleIntDown(); static void handleIntUp(); static void handleIntLeft(); diff --git a/src/input/UpDownInterruptBase.cpp b/src/input/UpDownInterruptBase.cpp index 979489c57..9a95323fe 100644 --- a/src/input/UpDownInterruptBase.cpp +++ b/src/input/UpDownInterruptBase.cpp @@ -6,8 +6,9 @@ UpDownInterruptBase::UpDownInterruptBase(const char *name) : concurrency::OSThre this->_originName = name; } -void UpDownInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, char eventDown, char eventUp, char eventPressed, - void (*onIntDown)(), void (*onIntUp)(), void (*onIntPress)()) +void UpDownInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, input_broker_event eventDown, + input_broker_event eventUp, input_broker_event eventPressed, void (*onIntDown)(), + void (*onIntUp)(), void (*onIntPress)()) { this->_pinDown = pinDown; this->_pinUp = pinUp; @@ -31,7 +32,7 @@ void UpDownInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, int32_t UpDownInterruptBase::runOnce() { InputEvent e; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; if (this->action == UPDOWN_ACTION_PRESSED) { LOG_DEBUG("GPIO event Press"); @@ -44,9 +45,9 @@ int32_t UpDownInterruptBase::runOnce() e.inputEvent = this->_eventDown; } - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE) { e.source = this->_originName; - e.kbchar = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.kbchar = INPUT_BROKER_NONE; this->notifyObservers(&e); } diff --git a/src/input/UpDownInterruptBase.h b/src/input/UpDownInterruptBase.h index 7060a0d80..4e9f591b9 100644 --- a/src/input/UpDownInterruptBase.h +++ b/src/input/UpDownInterruptBase.h @@ -7,8 +7,8 @@ class UpDownInterruptBase : public Observable, public concur { public: explicit UpDownInterruptBase(const char *name); - void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, char eventDown, char eventUp, char eventPressed, - void (*onIntDown)(), void (*onIntUp)(), void (*onIntPress)()); + void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, + input_broker_event eventPressed, void (*onIntDown)(), void (*onIntUp)(), void (*onIntPress)()); void intPressHandler(); void intDownHandler(); void intUpHandler(); @@ -23,8 +23,8 @@ class UpDownInterruptBase : public Observable, public concur private: uint8_t _pinDown = 0; uint8_t _pinUp = 0; - char _eventDown = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; - char _eventUp = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; - char _eventPressed = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + input_broker_event _eventDown = INPUT_BROKER_NONE; + input_broker_event _eventUp = INPUT_BROKER_NONE; + input_broker_event _eventPressed = INPUT_BROKER_NONE; const char *_originName; }; diff --git a/src/input/UpDownInterruptImpl1.cpp b/src/input/UpDownInterruptImpl1.cpp index 7dd1f76b2..761b92348 100644 --- a/src/input/UpDownInterruptImpl1.cpp +++ b/src/input/UpDownInterruptImpl1.cpp @@ -17,9 +17,9 @@ bool UpDownInterruptImpl1::init() uint8_t pinDown = moduleConfig.canned_message.inputbroker_pin_b; uint8_t pinPress = moduleConfig.canned_message.inputbroker_pin_press; - char eventDown = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN); - char eventUp = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP); - char eventPressed = static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT); + input_broker_event eventDown = INPUT_BROKER_DOWN; + input_broker_event eventUp = INPUT_BROKER_UP; + input_broker_event eventPressed = INPUT_BROKER_SELECT; UpDownInterruptBase::init(pinDown, pinUp, pinPress, eventDown, eventUp, eventPressed, UpDownInterruptImpl1::handleIntDown, UpDownInterruptImpl1::handleIntUp, UpDownInterruptImpl1::handleIntPressed); diff --git a/src/input/kbI2cBase.cpp b/src/input/kbI2cBase.cpp index 70e9e4365..5cc069816 100644 --- a/src/input/kbI2cBase.cpp +++ b/src/input/kbI2cBase.cpp @@ -75,94 +75,94 @@ int32_t KbI2cBase::runOnce() const BBQ10Keyboard::KeyEvent key = Q10keyboard.keyEvent(); if ((key.key != 0x00) && (key.state == BBQ10Keyboard::StateRelease)) { InputEvent e; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; switch (key.key) { case 'p': // TAB case 't': // TAB as well if (is_sym) { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = 0x09; // TAB Scancode is_sym = false; // reset sym state after second keypress } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = key.key; } break; case 'q': // ESC if (is_sym) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL; - e.kbchar = 0x1b; + e.inputEvent = INPUT_BROKER_CANCEL; + e.kbchar = 0; is_sym = false; // reset sym state after second keypress } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = key.key; } break; case 0x08: // Back - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK; + e.inputEvent = INPUT_BROKER_BACK; e.kbchar = key.key; break; case 'e': // sym e if (is_sym) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP; - e.kbchar = INPUT_BROKER_MSG_UP; + e.inputEvent = INPUT_BROKER_UP; + e.kbchar = INPUT_BROKER_UP; is_sym = false; // reset sym state after second keypress } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = key.key; } break; case 'x': // sym x if (is_sym) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN; - e.kbchar = INPUT_BROKER_MSG_DOWN; + e.inputEvent = INPUT_BROKER_DOWN; + e.kbchar = 0; is_sym = false; // reset sym state after second keypress } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = key.key; } break; case 's': // sym s if (is_sym) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT; + e.inputEvent = INPUT_BROKER_LEFT; e.kbchar = 0x00; // tweak for destSelect is_sym = false; // reset sym state after second keypress } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = key.key; } break; case 'f': // sym f if (is_sym) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT; + e.inputEvent = INPUT_BROKER_RIGHT; e.kbchar = 0x00; // tweak for destSelect is_sym = false; // reset sym state after second keypress } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = key.key; } break; case 0x13: // Code scanner says the SYM key is 0x13 is_sym = !is_sym; - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = is_sym ? INPUT_BROKER_MSG_FN_SYMBOL_ON // send 0xf1 to tell CannedMessages to display that : INPUT_BROKER_MSG_FN_SYMBOL_OFF; // the modifier key is active break; case 0x0a: // apparently Enter on Q10 is a line feed instead of carriage return - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; + e.inputEvent = INPUT_BROKER_SELECT; break; case 0x00: // nopress - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; break; default: // all other keys - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = key.key; is_sym = false; // reset sym state after second keypress break; } - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE) { this->notifyObservers(&e); } } @@ -175,57 +175,57 @@ int32_t KbI2cBase::runOnce() while (MPRkeyboard.hasEvent()) { char nextEvent = MPRkeyboard.dequeueEvent(); - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = 0x00; e.source = this->_originName; switch (nextEvent) { case 0x00: // MPR121_NONE - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.kbchar = 0x00; break; case 0x90: // MPR121_REBOOT - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = INPUT_BROKER_MSG_REBOOT; break; case 0xb4: // MPR121_LEFT - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT; + e.inputEvent = INPUT_BROKER_LEFT; e.kbchar = 0x00; break; case 0xb5: // MPR121_UP - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP; + e.inputEvent = INPUT_BROKER_UP; e.kbchar = 0x00; break; case 0xb6: // MPR121_DOWN - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN; + e.inputEvent = INPUT_BROKER_DOWN; e.kbchar = 0x00; break; case 0xb7: // MPR121_RIGHT - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT; + e.inputEvent = INPUT_BROKER_RIGHT; e.kbchar = 0x00; break; case 0x1b: // MPR121_ESC - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL; - e.kbchar = 0x1b; + e.inputEvent = INPUT_BROKER_CANCEL; + e.kbchar = 0; break; case 0x08: // MPR121_BSP - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK; + e.inputEvent = INPUT_BROKER_BACK; e.kbchar = 0x08; break; case 0x0d: // MPR121_SELECT - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; - e.kbchar = 0x0d; + e.inputEvent = INPUT_BROKER_SELECT; + e.kbchar = 0x00; break; default: if (nextEvent > 127) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.kbchar = 0x00; break; } - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = nextEvent; break; } - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE) { LOG_DEBUG("MP121 Notifying: %i Char: %i", e.inputEvent, e.kbchar); this->notifyObservers(&e); } @@ -237,57 +237,57 @@ int32_t KbI2cBase::runOnce() InputEvent e; while (TCAKeyboard.hasEvent()) { char nextEvent = TCAKeyboard.dequeueEvent(); - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = 0x00; e.source = this->_originName; switch (nextEvent) { case _TCA8418_NONE: - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.kbchar = 0x00; break; case _TCA8418_REBOOT: - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = INPUT_BROKER_MSG_REBOOT; break; case _TCA8418_LEFT: - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT; + e.inputEvent = INPUT_BROKER_LEFT; e.kbchar = 0x00; break; case _TCA8418_UP: - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP; + e.inputEvent = INPUT_BROKER_UP; e.kbchar = 0x00; break; case _TCA8418_DOWN: - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN; + e.inputEvent = INPUT_BROKER_DOWN; e.kbchar = 0x00; break; case _TCA8418_RIGHT: - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT; + e.inputEvent = INPUT_BROKER_RIGHT; e.kbchar = 0x00; break; case _TCA8418_BSP: - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK; + e.inputEvent = INPUT_BROKER_BACK; e.kbchar = 0x08; break; case _TCA8418_SELECT: - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; - e.kbchar = 0x0d; + e.inputEvent = INPUT_BROKER_SELECT; + e.kbchar = 0x00; break; case _TCA8418_ESC: - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL; - e.kbchar = 0x1b; + e.inputEvent = INPUT_BROKER_CANCEL; + e.kbchar = 0; break; default: if (nextEvent > 127) { - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.kbchar = 0x00; break; } - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = nextEvent; break; } - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE) { LOG_DEBUG("TCA8418 Notifying: %i Char: %c", e.inputEvent, e.kbchar); this->notifyObservers(&e); } @@ -310,7 +310,7 @@ int32_t KbI2cBase::runOnce() if (PrintDataBuf != 0) { LOG_DEBUG("RAK14004 key 0x%x pressed", PrintDataBuf); InputEvent e; - e.inputEvent = MATRIXKEY; + e.inputEvent = INPUT_BROKER_MATRIXKEY; e.source = this->_originName; e.kbchar = PrintDataBuf; this->notifyObservers(&e); @@ -325,138 +325,150 @@ int32_t KbI2cBase::runOnce() if (i2cBus->available()) { char c = i2cBus->read(); InputEvent e; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; switch (c) { case 0x71: // This is the button q. If modifier and q pressed, it cancels the input if (is_sym) { is_sym = false; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL; + e.inputEvent = INPUT_BROKER_CANCEL; } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = c; } break; case 0x74: // letter t. if modifier and t pressed call 'tab' if (is_sym) { is_sym = false; - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = 0x09; // TAB Scancode } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = c; } break; case 0x6d: // letter m. Modifier makes it mute notifications if (is_sym) { is_sym = false; - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = INPUT_BROKER_MSG_MUTE_TOGGLE; // mute notifications } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = c; } break; case 0x6f: // letter o(+). Modifier makes screen increase in brightness if (is_sym) { is_sym = false; - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = INPUT_BROKER_MSG_BRIGHTNESS_UP; // Increase Brightness code } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = c; } break; case 0x69: // letter i(-). Modifier makes screen decrease in brightness if (is_sym) { is_sym = false; - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = INPUT_BROKER_MSG_BRIGHTNESS_DOWN; // Decrease Brightness code } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = c; } break; case 0x20: // Space. Send network ping like double press does if (is_sym) { is_sym = false; - e.inputEvent = ANYKEY; - e.kbchar = INPUT_BROKER_MSG_SEND_PING; // (fn + space) + e.inputEvent = INPUT_BROKER_ANYKEY; + e.kbchar = INPUT_BROKER_SEND_PING; // (fn + space) } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = c; } break; case 0x67: // letter g. toggle gps if (is_sym) { is_sym = false; - e.inputEvent = ANYKEY; - e.kbchar = INPUT_BROKER_MSG_GPS_TOGGLE; + e.inputEvent = INPUT_BROKER_GPS_TOGGLE; + e.kbchar = INPUT_BROKER_GPS_TOGGLE; } else { - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = c; } break; case 0x1b: // ESC - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL; + e.inputEvent = INPUT_BROKER_CANCEL; break; case 0x08: // Back - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK; - e.kbchar = c; + e.inputEvent = INPUT_BROKER_BACK; + e.kbchar = 0; break; case 0xb5: // Up - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP; - e.kbchar = INPUT_BROKER_MSG_UP; + e.inputEvent = INPUT_BROKER_UP; + e.kbchar = 0; break; case 0xb6: // Down - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN; - e.kbchar = INPUT_BROKER_MSG_DOWN; + e.inputEvent = INPUT_BROKER_DOWN; + e.kbchar = 0; break; case 0xb4: // Left - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT; - e.kbchar = INPUT_BROKER_MSG_LEFT; + e.inputEvent = INPUT_BROKER_LEFT; + e.kbchar = 0; break; case 0xb7: // Right - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT; - e.kbchar = INPUT_BROKER_MSG_RIGHT; + e.inputEvent = INPUT_BROKER_RIGHT; + e.kbchar = 0; break; case 0xc: // Modifier key: 0xc is alt+c (Other options could be: 0xea = shift+mic button or 0x4 shift+$(speaker)) // toggle moddifiers button. is_sym = !is_sym; - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = is_sym ? INPUT_BROKER_MSG_FN_SYMBOL_ON // send 0xf1 to tell CannedMessages to display that the : INPUT_BROKER_MSG_FN_SYMBOL_OFF; // modifier key is active break; + case 0x9e: // fn+g INPUT_BROKER_GPS_TOGGLE + e.inputEvent = INPUT_BROKER_GPS_TOGGLE; + e.kbchar = c; + break; + case 0xaf: // fn+space INPUT_BROKER_SEND_PING + e.inputEvent = INPUT_BROKER_SEND_PING; + e.kbchar = c; + break; + case 0x9b: // fn+s INPUT_BROKER_MSG_SHUTDOWN + e.inputEvent = INPUT_BROKER_SHUTDOWN; + e.kbchar = c; + break; + case 0x90: // fn+r INPUT_BROKER_MSG_REBOOT case 0x91: // fn+t - case 0x9b: // fn+s INPUT_BROKER_MSG_SHUTDOWN case 0xac: // fn+m INPUT_BROKER_MSG_MUTE_TOGGLE - case 0x9e: // fn+g INPUT_BROKER_MSG_GPS_TOGGLE - case 0xaf: // fn+space INPUT_BROKER_MSG_SEND_PING + case 0x8b: // fn+del INPUT_BROKEN_MSG_DISMISS_FRAME case 0xAA: // fn+b INPUT_BROKER_MSG_BLUETOOTH_TOGGLE + case 0x8F: // fn+e INPUT_BROKER_MSG_EMOTE_LIST // just pass those unmodified - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = c; break; case 0x0d: // Enter - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; + e.inputEvent = INPUT_BROKER_SELECT; break; case 0x00: // nopress - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; break; default: // all other keys if (c > 127) { // bogus key value - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; break; } - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = c; is_sym = false; break; } - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE) { this->notifyObservers(&e); } } diff --git a/src/input/kbMatrixBase.cpp b/src/input/kbMatrixBase.cpp index 51815b525..05f4d8177 100644 --- a/src/input/kbMatrixBase.cpp +++ b/src/input/kbMatrixBase.cpp @@ -73,35 +73,35 @@ int32_t KbMatrixBase::runOnce() LOG_DEBUG("Key 0x%x pressed", key); // reset shift now that we have a keypress InputEvent e; - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; switch (key) { case 0x1b: // ESC - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL; + e.inputEvent = INPUT_BROKER_CANCEL; break; case 0x08: // Back - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK; - e.kbchar = key; + e.inputEvent = INPUT_BROKER_BACK; + e.kbchar = 0; break; case 0xb5: // Up - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP; + e.inputEvent = INPUT_BROKER_UP; break; case 0xb6: // Down - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN; + e.inputEvent = INPUT_BROKER_DOWN; break; case 0xb4: // Left - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT; - e.kbchar = key; + e.inputEvent = INPUT_BROKER_LEFT; + e.kbchar = 0; break; case 0xb7: // Right - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT; - e.kbchar = key; + e.inputEvent = INPUT_BROKER_RIGHT; + e.kbchar = 0; break; case 0x0d: // Enter - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; + e.inputEvent = INPUT_BROKER_SELECT; break; case 0x00: // nopress - e.inputEvent = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE; + e.inputEvent = INPUT_BROKER_NONE; break; case 0x1a: // Shift shift++; @@ -110,11 +110,11 @@ int32_t KbMatrixBase::runOnce() } break; default: // all other keys - e.inputEvent = ANYKEY; + e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = key; break; } - if (e.inputEvent != meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE) { this->notifyObservers(&e); } } diff --git a/src/main.cpp b/src/main.cpp index 2c30d4718..17214b13f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -99,7 +99,24 @@ NRF52Bluetooth *nrf52Bluetooth = nullptr; #endif #if HAS_BUTTON || defined(ARCH_PORTDUINO) -#include "ButtonThread.h" +#include "input/ButtonThread.h" + +#if defined(BUTTON_PIN_TOUCH) +ButtonThread *TouchButtonThread = nullptr; +#endif + +#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) +ButtonThread *UserButtonThread = nullptr; +#endif + +#if defined(ALT_BUTTON_PIN) +ButtonThread *BackButtonThread = nullptr; +#endif + +#if defined(CANCEL_BUTTON_PIN) +ButtonThread *CancelButtonThread = nullptr; +#endif + #endif #include "AmbientLightingThread.h" @@ -169,6 +186,8 @@ ScanI2C::DeviceAddress screen_found = ScanI2C::ADDRESS_NONE; ScanI2C::DeviceAddress cardkb_found = ScanI2C::ADDRESS_NONE; // 0x02 for RAK14004, 0x00 for cardkb, 0x10 for T-Deck uint8_t kb_model; +// global bool to record that a kb is present +bool kb_found = false; // The I2C address of the RTC Module (if found) ScanI2C::DeviceAddress rtc_found = ScanI2C::ADDRESS_NONE; @@ -220,64 +239,6 @@ const char *getDeviceName() return name; } -#if defined(ELECROW_ThinkNode_M1) || defined(ELECROW_ThinkNode_M2) -static int32_t ledBlinkCount = 0; - -static int32_t elecrowLedBlinker() -{ - // are we in alert buzzer mode? -#if HAS_BUTTON - if (buttonThread->isBuzzing()) { - // blink LED three times for 3 seconds, then 3 times for a second, with one second pause - if (ledBlinkCount % 2) { // odd means LED OFF - ledBlink.set(false); - ledBlinkCount++; - if (ledBlinkCount >= 12) - ledBlinkCount = 0; - noTone(PIN_BUZZER); - return 1000; - } else { - if (ledBlinkCount < 6) { - ledBlink.set(true); - tone(PIN_BUZZER, 4000, 3000); - ledBlinkCount++; - return 3000; - } else { - ledBlink.set(true); - tone(PIN_BUZZER, 4000, 1000); - ledBlinkCount++; - return 1000; - } - } - } else { -#endif - ledBlinkCount = 0; - if (config.device.led_heartbeat_disabled) - return 1000; - - static bool ledOn; - // remain on when fully charged or discharging above 10% - if ((powerStatus->getIsCharging() && powerStatus->getBatteryChargePercent() >= 100) || - (!powerStatus->getIsCharging() && powerStatus->getBatteryChargePercent() >= 10)) { - ledOn = true; - } else { - ledOn ^= 1; - } - ledBlink.set(ledOn); - // when charging, blink 0.5Hz square wave rate to indicate that - if (powerStatus->getIsCharging()) { - return 500; - } - // Blink rapidly when almost empty or if battery is not connected - if ((!powerStatus->getIsCharging() && powerStatus->getBatteryChargePercent() < 10) || !powerStatus->getHasBattery()) { - return 250; - } -#if HAS_BUTTON - } -#endif - return 1000; -} -#else static int32_t ledBlinker() { // Still set up the blinking (heartbeat) interval but skip code path below, so LED will blink if @@ -293,7 +254,6 @@ static int32_t ledBlinker() // have a very sparse duty cycle of LED being on, unless charging, then blink 0.5Hz square wave rate to indicate that return powerStatus->getIsCharging() ? 1000 : (ledOn ? 1 : 1000); } -#endif uint32_t timeLastPowered = 0; @@ -382,11 +342,9 @@ void setup() SPISettings spiSettings(4000000, MSBFIRST, SPI_MODE0); #endif -#if !HAS_TFT meshtastic_Config_DisplayConfig_OledType screen_model = meshtastic_Config_DisplayConfig_OledType::meshtastic_Config_DisplayConfig_OledType_OLED_AUTO; OLEDDISPLAY_GEOMETRY screen_geometry = GEOMETRY_128_64; -#endif #ifdef USE_SEGGER auto mode = false ? SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL : SEGGER_RTT_MODE_NO_BLOCK_TRIM; @@ -475,6 +433,10 @@ void setup() gpio_pullup_en((gpio_num_t)(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN)); delay(10); #endif +#ifdef BUTTON_NEED_PULLUP2 + gpio_pullup_en((gpio_num_t)BUTTON_NEED_PULLUP2); + delay(10); +#endif #endif #endif #endif @@ -485,7 +447,7 @@ void setup() #if defined(ELECROW_ThinkNode_M1) || defined(ELECROW_ThinkNode_M2) // The ThinkNodes have their own blink logic - ledPeriodic = new Periodic("Blink", elecrowLedBlinker); + // ledPeriodic = new Periodic("Blink", elecrowLedBlinker); #else ledPeriodic = new Periodic("Blink", ledBlinker); #endif @@ -536,10 +498,6 @@ void setup() digitalWrite(AQ_SET_PIN, HIGH); #endif -#if HAS_TFT - tftSetup(); -#endif - // Currently only the tbeam has a PMU // PMU initialization needs to be placed before i2c scanning power = new Power(); @@ -602,7 +560,6 @@ void setup() } #endif -#if !HAS_TFT auto screenInfo = i2cScanner->firstScreen(); screen_found = screenInfo.type != ScanI2C::DeviceType::NONE ? screenInfo.address : ScanI2C::ADDRESS_NONE; @@ -620,16 +577,18 @@ void setup() screen_model = meshtastic_Config_DisplayConfig_OledType::meshtastic_Config_DisplayConfig_OledType_OLED_AUTO; } } -#endif #define UPDATE_FROM_SCANNER(FIND_FN) - +#if defined(USE_VIRTUAL_KEYBOARD) + kb_found = true; +#endif auto rtc_info = i2cScanner->firstRTC(); rtc_found = rtc_info.type != ScanI2C::DeviceType::NONE ? rtc_info.address : rtc_found; auto kb_info = i2cScanner->firstKeyboard(); if (kb_info.type != ScanI2C::DeviceType::NONE) { + kb_found = true; cardkb_found = kb_info.address; switch (kb_info.type) { case ScanI2C::DeviceType::RAK14004: @@ -768,6 +727,12 @@ void setup() // but we need to do this after main cpu init (esp32setup), because we need the random seed set nodeDB = new NodeDB; +#if HAS_TFT + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + tftSetup(); + } +#endif + // If we're taking on the repeater role, use NextHopRouter and turn off 3V3_S rail because peripherals are not needed if (config.device.role == meshtastic_Config_DeviceConfig_Role_REPEATER) { router = new NextHopRouter(); @@ -777,11 +742,6 @@ void setup() } else router = new ReliableRouter(); -#if HAS_BUTTON || defined(ARCH_PORTDUINO) - // Buttons. Moved here cause we need NodeDB to be initialized - buttonThread = new ButtonThread(); -#endif - // only play start melody when role is not tracker or sensor if (config.power.is_power_saving == true && IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_TRACKER, @@ -790,11 +750,9 @@ void setup() else playStartMelody(); -#if !HAS_TFT // fixed screen override? if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO) screen_model = config.display.oled; -#endif #if defined(USE_SH1107) screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // set dimension of 128x128 @@ -864,8 +822,23 @@ void setup() // Initialize the screen first so we can show the logo while we start up everything else. #if HAS_SCREEN - screen = new graphics::Screen(screen_found, screen_model, screen_geometry); + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + +#if defined(ST7701_CS) || defined(ST7735_CS) || defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \ + defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) + screen = new graphics::Screen(screen_found, screen_model, screen_geometry); +#elif defined(ARCH_PORTDUINO) + if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || settingsMap[displayPanel]) && + config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + screen = new graphics::Screen(screen_found, screen_model, screen_geometry); + } +#else + if (screen_found.port != ScanI2C::I2CPort::NO_I2C) + screen = new graphics::Screen(screen_found, screen_model, screen_geometry); #endif + } +#endif // HAS_SCREEN + // setup TZ prior to time actions. #if !MESHTASTIC_EXCLUDE_TZ LOG_DEBUG("Use compiled/slipstreamed %s", slipstreamTZString); // important, removing this clobbers our magic string @@ -937,6 +910,117 @@ void setup() // Now that the mesh service is created, create any modules setupModules(); +// buttons are now inputBroker, so have to come after setupModules +#if HAS_BUTTON + int pullup_sense = 0; +#ifdef INPUT_PULLUP_SENSE + // Some platforms (nrf52) have a SENSE variant which allows wake from sleep - override what OneButton did +#ifdef BUTTON_SENSE_TYPE + pullup_sense = BUTTON_SENSE_TYPE; +#else + pullup_sense = INPUT_PULLUP_SENSE; +#endif +#endif +#if defined(ARCH_PORTDUINO) + + if (settingsMap.count(userButtonPin) != 0 && settingsMap[userButtonPin] != RADIOLIB_NC) { + + LOG_DEBUG("Use GPIO%02d for button", settingsMap[userButtonPin]); + UserButtonThread = new ButtonThread("UserButton"); + if (screen) + UserButtonThread->initButton( + settingsMap[userButtonPin], true, true, INPUT_PULLUP, // pull up bias + []() { + UserButtonThread->userButton.tick(); + runASAP = true; + BaseType_t higherWake = 0; + mainDelay.interruptFromISR(&higherWake); + }, + INPUT_BROKER_USER_PRESS, INPUT_BROKER_SELECT); + } +#endif + +#ifdef BUTTON_PIN_TOUCH + TouchButtonThread = new ButtonThread("BackButton"); + TouchButtonThread->initButton( + BUTTON_PIN_TOUCH, true, true, pullup_sense, + []() { + TouchButtonThread->userButton.tick(); + runASAP = true; + BaseType_t higherWake = 0; + mainDelay.interruptFromISR(&higherWake); + }, + INPUT_BROKER_NONE, INPUT_BROKER_BACK); +#endif + +#if defined(CANCEL_BUTTON_PIN) + // Buttons. Moved here cause we need NodeDB to be initialized + CancelButtonThread = new ButtonThread("CancelButton"); + CancelButtonThread->initButton( + CANCEL_BUTTON_PIN, CANCEL_BUTTON_ACTIVE_LOW, CANCEL_BUTTON_ACTIVE_PULLUP, pullup_sense, + []() { + CancelButtonThread->userButton.tick(); + runASAP = true; + BaseType_t higherWake = 0; + mainDelay.interruptFromISR(&higherWake); + }, + INPUT_BROKER_CANCEL, INPUT_BROKER_SHUTDOWN, 4000); +#endif + +#if defined(ALT_BUTTON_PIN) + // Buttons. Moved here cause we need NodeDB to be initialized + BackButtonThread = new ButtonThread("BackButton"); + BackButtonThread->initButton( + ALT_BUTTON_PIN, ALT_BUTTON_ACTIVE_LOW, ALT_BUTTON_ACTIVE_PULLUP, pullup_sense, + []() { + BackButtonThread->userButton.tick(); + runASAP = true; + BaseType_t higherWake = 0; + mainDelay.interruptFromISR(&higherWake); + }, + INPUT_BROKER_ALT_PRESS, INPUT_BROKER_ALT_LONG, 500); +#endif + +#if defined(BUTTON_PIN) +#if defined(USERPREFS_BUTTON_PIN) + int _pinNum = config.device.button_gpio ? config.device.button_gpio : USERPREFS_BUTTON_PIN; +#else + int _pinNum = config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN; +#endif +#ifndef BUTTON_ACTIVE_LOW +#define BUTTON_ACTIVE_LOW true +#endif +#ifndef BUTTON_ACTIVE_PULLUP +#define BUTTON_ACTIVE_PULLUP true +#endif + + // Buttons. Moved here cause we need NodeDB to be initialized + // If your variant.h has a BUTTON_PIN defined, go ahead and define BUTTON_ACTIVE_LOW and BUTTON_ACTIVE_PULLUP + UserButtonThread = new ButtonThread("UserButton"); + if (screen) + UserButtonThread->initButton( + _pinNum, BUTTON_ACTIVE_LOW, BUTTON_ACTIVE_PULLUP, pullup_sense, + []() { + UserButtonThread->userButton.tick(); + runASAP = true; + BaseType_t higherWake = 0; + mainDelay.interruptFromISR(&higherWake); + }, + INPUT_BROKER_USER_PRESS, INPUT_BROKER_SELECT, 500, INPUT_BROKER_NONE, INPUT_BROKER_SHUTDOWN); + else + UserButtonThread->initButton( + _pinNum, BUTTON_ACTIVE_LOW, BUTTON_ACTIVE_PULLUP, pullup_sense, + []() { + UserButtonThread->userButton.tick(); + runASAP = true; + BaseType_t higherWake = 0; + mainDelay.interruptFromISR(&higherWake); + }, + INPUT_BROKER_USER_PRESS, INPUT_BROKER_SHUTDOWN, 5000, INPUT_BROKER_SEND_PING, INPUT_BROKER_GPS_TOGGLE); +#endif + +#endif + #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS // After modules are setup, so we can observe modules setupNicheGraphics(); @@ -959,19 +1043,19 @@ void setup() // the current region name) #if defined(ST7701_CS) || defined(ST7735_CS) || defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \ defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) - screen->setup(); + if (screen) + screen->setup(); #elif defined(ARCH_PORTDUINO) - if (screen_found.port != ScanI2C::I2CPort::NO_I2C || settingsMap[displayPanel]) { + if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || settingsMap[displayPanel]) && + config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { screen->setup(); } #else - if (screen_found.port != ScanI2C::I2CPort::NO_I2C) + if (screen_found.port != ScanI2C::I2CPort::NO_I2C && screen) screen->setup(); #endif #endif - screen->print("Started...\n"); - #ifdef PIN_PWR_DELAY_MS // This may be required to give the peripherals time to power up. delay(PIN_PWR_DELAY_MS); @@ -1230,9 +1314,12 @@ void setup() LOG_WARN("LoRa chip does not support 2.4GHz. Revert to unset"); config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; nodeDB->saveToDisk(SEGMENT_CONFIG); + if (!rIf->reconfigure()) { LOG_WARN("Reconfigure failed, rebooting"); - screen->startAlert("Rebooting..."); + if (screen) { + screen->showOverlayBanner("Rebooting..."); + } rebootAtMsec = millis() + 5000; } } diff --git a/src/main.h b/src/main.h index beeb1f940..79094e2d3 100644 --- a/src/main.h +++ b/src/main.h @@ -31,13 +31,13 @@ extern HardwareSPI *LoraSPI; extern ScanI2C::DeviceAddress screen_found; extern ScanI2C::DeviceAddress cardkb_found; extern uint8_t kb_model; +extern bool kb_found; extern ScanI2C::DeviceAddress rtc_found; extern ScanI2C::DeviceAddress accelerometer_found; extern ScanI2C::FoundDevice rgb_found; extern bool eink_found; extern bool pmu_found; -extern bool isCharging; extern bool isUSBPowered; #ifdef T_WATCH_S3 diff --git a/src/mesh/MeshPacketQueue.cpp b/src/mesh/MeshPacketQueue.cpp index 0c312fd1e..f8af81321 100644 --- a/src/mesh/MeshPacketQueue.cpp +++ b/src/mesh/MeshPacketQueue.cpp @@ -118,10 +118,10 @@ meshtastic_MeshPacket *MeshPacketQueue::remove(NodeNum from, PacketId id, bool t } /* Attempt to find a packet from this queue. Return true if it was found. */ -bool MeshPacketQueue::find(NodeNum from, PacketId id) +bool MeshPacketQueue::find(const NodeNum from, const PacketId id) { for (auto it = queue.begin(); it != queue.end(); it++) { - auto p = (*it); + const auto p = (*it); if (getFrom(p) == from && p->id == id) { return true; } diff --git a/src/mesh/MeshPacketQueue.h b/src/mesh/MeshPacketQueue.h index 6b2c3998a..1b338f9ed 100644 --- a/src/mesh/MeshPacketQueue.h +++ b/src/mesh/MeshPacketQueue.h @@ -39,5 +39,5 @@ class MeshPacketQueue meshtastic_MeshPacket *remove(NodeNum from, PacketId id, bool tx_normal = true, bool tx_late = true); /* Attempt to find a packet from this queue. Return true if it was found. */ - bool find(NodeNum from, PacketId id); + bool find(const NodeNum from, const PacketId id); }; \ No newline at end of file diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index b1ec7b347..d13864bd9 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -511,6 +511,10 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.lora.override_duty_cycle = false; config.lora.config_ok_to_mqtt = false; +#if HAS_TFT // For the devices that support MUI, default to that + config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR; +#endif + #ifdef USERPREFS_CONFIG_DEVICE_ROLE // Restrict ROUTER*, LOST AND FOUND, and REPEATER roles for security reasons if (IS_ONE_OF(USERPREFS_CONFIG_DEVICE_ROLE, meshtastic_Config_DeviceConfig_Role_ROUTER, @@ -788,15 +792,7 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.external_notification.output_ms = 1000; moduleConfig.external_notification.nag_timeout = 60; #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; - #if USERPREFS_MQTT_ENABLED && !MESHTASTIC_EXCLUDE_MQTT moduleConfig.mqtt.enabled = true; #endif @@ -1561,7 +1557,7 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) info->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; // Mark the node's key as manually verified to indicate trustworthiness. updateGUIforNode = info; - powerFSM.trigger(EVENT_NODEDB_UPDATED); + // powerFSM.trigger(EVENT_NODEDB_UPDATED); This event has been retired notifyObservers(true); // Force an update whether or not our node counts have changed } saveNodeDatabaseToDisk(); @@ -1620,7 +1616,6 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde if (changed) { updateGUIforNode = info; - powerFSM.trigger(EVENT_NODEDB_UPDATED); notifyObservers(true); // Force an update whether or not our node counts have changed // We just changed something about a User, @@ -1891,10 +1886,6 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, /// Record an error that should be reported via analytics void recordCriticalError(meshtastic_CriticalErrorCode code, uint32_t address, const char *filename) { - // Print error to screen and serial port - String lcd = String("Critical error ") + code + "!\n"; - if (screen) - screen->print(lcd.c_str()); if (filename) { LOG_ERROR("NOTE! Record critical error %d at %s:%lu", code, filename, address); } else { diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index 6b8ccde76..f42b151c8 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -8,7 +8,8 @@ #include "Throttle.h" #define PACKETHISTORY_MAX \ - max((int)(MAX_NUM_NODES * 2.0), 100) // x2..3 Should suffice. Empirical setup. 16B per record malloc'ed, but no less than 100 + max((u_int32_t)(MAX_NUM_NODES * 2.0), \ + (u_int32_t)100) // x2..3 Should suffice. Empirical setup. 16B per record malloc'ed, but no less than 100 #define RECENT_WARN_AGE (10 * 60 * 1000L) // Warn if the packet that gets removed was more recent than 10 min diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index 70c6e3fe4..9c92a6c27 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -64,7 +64,9 @@ static int32_t reconnectETH() } #if !MESHTASTIC_EXCLUDE_SOCKETAPI - initApiServer(); + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + initApiServer(); + } #endif ethStartupComplete = true; diff --git a/src/mesh/http/ContentHandler.cpp b/src/mesh/http/ContentHandler.cpp index 5841fe478..42ebb8417 100644 --- a/src/mesh/http/ContentHandler.cpp +++ b/src/mesh/http/ContentHandler.cpp @@ -903,7 +903,8 @@ void handleBlinkLED(HTTPRequest *req, HTTPResponse *res) } } else { #if HAS_SCREEN - screen->blink(); + if (screen) + screen->blink(); #endif } diff --git a/src/mesh/http/WebServer.cpp b/src/mesh/http/WebServer.cpp index 5f6ad9eb3..bf170de59 100644 --- a/src/mesh/http/WebServer.cpp +++ b/src/mesh/http/WebServer.cpp @@ -154,7 +154,8 @@ void createSSLCert() esp_task_wdt_reset(); #if HAS_SCREEN if (millis() / 1000 >= 3) { - screen->setSSLFrames(); + if (screen) + screen->setSSLFrames(); } #endif } diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 115817aab..24be97ad7 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -128,10 +128,14 @@ static void onNetworkConnected() } #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WEBSERVER - initWebServer(); + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + initWebServer(); + } #endif #if !MESHTASTIC_EXCLUDE_SOCKETAPI - initApiServer(); + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + initApiServer(); + } #endif APStartupComplete = true; } diff --git a/src/meshUtils.h b/src/meshUtils.h index 47d42b41b..35b88e8b2 100644 --- a/src/meshUtils.h +++ b/src/meshUtils.h @@ -11,6 +11,14 @@ template constexpr const T &clamp(const T &v, const T &lo, const T &hi return (v < lo) ? lo : (hi < v) ? hi : v; } +#if HAS_SCREEN +#define IF_SCREEN(X) \ + if (screen) \ + X; +#else +#define IF_SCREEN(...) +#endif + #if (defined(ARCH_PORTDUINO) && !defined(STRNSTR)) #define STRNSTR #include diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 551602f00..b68a3a1a4 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -5,6 +5,7 @@ #include "PowerFSM.h" #include "RTC.h" #include "SPILock.h" +#include "input/InputBroker.h" #include "meshUtils.h" #include #include // for better whitespace handling @@ -223,14 +224,16 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #if defined(ARCH_ESP32) #if !MESHTASTIC_EXCLUDE_BLUETOOTH if (!BleOta::getOtaAppVersion().isEmpty()) { - screen->startFirmwareUpdateScreen(); + if (screen) + screen->startFirmwareUpdateScreen(); BleOta::switchToOtaApp(); LOG_INFO("Rebooting to BLE OTA"); } #endif #if !MESHTASTIC_EXCLUDE_WIFI if (WiFiOTA::trySwitchToOTA()) { - screen->startFirmwareUpdateScreen(); + if (screen) + screen->startFirmwareUpdateScreen(); WiFiOTA::saveConfig(&config.network); LOG_INFO("Rebooting to WiFi OTA"); } @@ -320,6 +323,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta if (node != NULL) { node->is_favorite = true; saveChanges(SEGMENT_NODEDATABASE, false); + if (screen) + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens } break; } @@ -329,6 +334,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta if (node != NULL) { node->is_favorite = false; saveChanges(SEGMENT_NODEDATABASE, false); + if (screen) + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens } break; } @@ -443,6 +450,11 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #endif break; } + case meshtastic_AdminMessage_send_input_event_tag: { + LOG_INFO("Client requesting to send input event"); + handleSendInputEvent(r->send_input_event); + break; + } #ifdef ARCH_PORTDUINO case meshtastic_AdminMessage_exit_simulator_tag: LOG_INFO("Exiting simulator"); @@ -530,7 +542,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) if (owner.has_is_unmessagable != o.has_is_unmessagable || (o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) { changed = 1; - owner.has_is_unmessagable = o.has_is_unmessagable || o.has_is_unmessagable; + owner.has_is_unmessagable = owner.has_is_unmessagable || o.has_is_unmessagable; owner.is_unmessagable = o.is_unmessagable; } @@ -643,8 +655,12 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) config.has_display = true; if (config.display.screen_on_secs == c.payload_variant.display.screen_on_secs && config.display.flip_screen == c.payload_variant.display.flip_screen && - config.display.oled == c.payload_variant.display.oled) { + config.display.oled == c.payload_variant.display.oled && + config.display.displaymode == c.payload_variant.display.displaymode) { requiresReboot = false; + } else if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR && + c.payload_variant.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + config.bluetooth.enabled = false; } #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR if (config.display.wake_on_tap_or_motion == false && c.payload_variant.display.wake_on_tap_or_motion == true && @@ -1157,7 +1173,8 @@ void AdminModule::handleGetDeviceUIConfig(const meshtastic_MeshPacket &req) void AdminModule::reboot(int32_t seconds) { LOG_INFO("Reboot in %d seconds", seconds); - screen->startAlert("Rebooting..."); + if (screen) + screen->showOverlayBanner("Rebooting...", 0); // stays on screen rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000); } @@ -1288,6 +1305,39 @@ bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r) return false; } +void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent) +{ + LOG_DEBUG("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code, + inputEvent.kb_char, inputEvent.touch_x, inputEvent.touch_y); + + // Validate input parameters + if (inputEvent.event_code > INPUT_BROKER_ANYKEY) { + LOG_WARN("Invalid input event code: %u", inputEvent.event_code); + return; + } + + // Create InputEvent for injection + InputEvent event = {.inputEvent = (input_broker_event)inputEvent.event_code, + .kbchar = (unsigned char)inputEvent.kb_char, + .touchX = inputEvent.touch_x, + .touchY = inputEvent.touch_y}; + + // Log the event being injected + LOG_INFO("Injecting input event from admin: source=%s, event=%u, char=%c(%u), touch=(%u,%u)", event.source, event.inputEvent, + (event.kbchar >= 32 && event.kbchar <= 126) ? event.kbchar : '?', event.kbchar, event.touchX, event.touchY); + + // Wake the device if asleep + powerFSM.trigger(EVENT_INPUT); +#if !defined(MESHTASTIC_EXCLUDE_INPUTBROKER) + // Inject the event through InputBroker + if (inputBroker) { + inputBroker->injectInputEvent(&event); + } else { + LOG_ERROR("InputBroker not available for event injection"); + } +#endif +} + void AdminModule::sendWarning(const char *message) { meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 246d39e37..5638e57e7 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -54,6 +54,7 @@ class AdminModule : public ProtobufModule, public Obser void handleSetChannel(); void handleSetHamMode(const meshtastic_HamParameters &req); void handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg); + void handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent); void reboot(int32_t seconds); void setPassKey(meshtastic_AdminMessage *res); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index c16c0e4b3..b24f3ca00 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -8,14 +8,16 @@ #include "FSCommon.h" #include "MeshService.h" #include "NodeDB.h" -#include "PowerFSM.h" // needed for button bypass #include "SPILock.h" +#include "buzz.h" #include "detect/ScanI2C.h" -#include "input/ScanAndSelect.h" +#include "graphics/Screen.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/emotes.h" +#include "graphics/images.h" +#include "main.h" // for cardkb_found #include "mesh/generated/meshtastic/cannedmessages.pb.h" #include "modules/AdminModule.h" - -#include "main.h" // for cardkb_found #include "modules/ExternalNotificationModule.h" // for buzzer control #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" @@ -35,6 +37,7 @@ #define INACTIVATE_AFTER_MS 20000 extern ScanI2C::DeviceAddress cardkb_found; +extern bool graphics::isMuted; static const char *cannedMessagesConfigFile = "/prefs/cannedConf.proto"; @@ -45,358 +48,763 @@ CannedMessageModule *cannedMessageModule; CannedMessageModule::CannedMessageModule() : SinglePortModule("canned", meshtastic_PortNum_TEXT_MESSAGE_APP), concurrency::OSThread("CannedMessage") { - 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"); - this->runState = CANNED_MESSAGE_RUN_STATE_DISABLED; - disable(); - } else { - LOG_INFO("CannedMessageModule is enabled"); - - // T-Watch interface currently has no way to select destination type, so default to 'node' -#if defined(USE_VIRTUAL_KEYBOARD) - this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NODE; -#endif - - this->inputObserver.observe(inputBroker); - } - } else { + 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"); this->runState = CANNED_MESSAGE_RUN_STATE_DISABLED; disable(); + } else { + LOG_INFO("CannedMessageModule is enabled"); + this->inputObserver.observe(inputBroker); } } +void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChannel) +{ + dest = newDest; + channel = newChannel; + // Always select the first real canned message on activation + int firstRealMsgIdx = 0; + for (int i = 0; i < messagesCount; ++i) { + if (strcmp(messages[i], "[Select Destination]") != 0 && strcmp(messages[i], "[Exit]") != 0 && + strcmp(messages[i], "[---- Free Text ----]") != 0) { + firstRealMsgIdx = i; + break; + } + } + currentMessageIndex = firstRealMsgIdx; + + // This triggers the canned message list + runState = CANNED_MESSAGE_RUN_STATE_ACTIVE; + requestFocus(); + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); +} + +void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t newChannel) +{ + dest = newDest; + channel = newChannel; + runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + requestFocus(); + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); +} + +static bool returnToCannedList = false; +bool hasKeyForNode(const meshtastic_NodeInfoLite *node) +{ + return node && node->has_user && node->user.public_key.size > 0; +} /** * @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 canned_messages = cannedMessageModuleConfig.messages; -#if defined(USE_VIRTUAL_KEYBOARD) - String separator = canned_messages.length() ? "|" : ""; - - canned_messages = "[---- Free Text ----]" + separator + canned_messages; -#endif - - // collect all the message parts + // Copy all message parts into the buffer strncpy(this->messageStore, canned_messages.c_str(), sizeof(this->messageStore)); - // The first message points to the beginning of the store. - this->messages[messageIndex++] = this->messageStore; + // Temporary array to allow for insertion + const char *tempMessages[CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT + 3] = {0}; + int tempCount = 0; + // Insert at position 0 (top) + tempMessages[tempCount++] = "[Select Destination]"; + +#if defined(USE_VIRTUAL_KEYBOARD) + // Add a "Free Text" entry at the top if using a keyboard + tempMessages[tempCount++] = "[-- Free Text --]"; +#endif + + // First message always starts at buffer start + tempMessages[tempCount++] = this->messageStore; int upTo = strlen(this->messageStore) - 1; + // Walk buffer, splitting on '|' 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); + this->messageStore[i] = '\0'; // End previous message + if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT) + break; + tempMessages[tempCount++] = (this->messageStore + i + 1); } i += 1; } - if (strlen(this->messages[messageIndex - 1]) > 0) { - // We have a last message. - LOG_DEBUG("CannedMessage %d is: '%s'", messageIndex - 1, this->messages[messageIndex - 1]); - this->messagesCount = messageIndex; - } else { - this->messagesCount = messageIndex - 1; + + // Add [Exit] as the last entry + tempMessages[tempCount++] = "[Exit]"; + + // Copy to the member array + for (int k = 0; k < tempCount; ++k) { + this->messages[k] = (char *)tempMessages[k]; } + this->messagesCount = tempCount; return this->messagesCount; } +void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, char *buffer) +{ + if (display->getWidth() > 128) { + if (this->dest == NODENUM_BROADCAST) { + display->drawStringf(x, y, buffer, "To: Broadcast@%s", channels.getName(this->channel)); + } else { + display->drawStringf(x, y, buffer, "To: %s", getNodeName(this->dest)); + } + } else { + if (this->dest == NODENUM_BROADCAST) { + display->drawStringf(x, y, buffer, "To: Broadc@%.5s", channels.getName(this->channel)); + } else { + display->drawStringf(x, y, buffer, "To: %s", getNodeName(this->dest)); + } + } +} +void CannedMessageModule::resetSearch() +{ + LOG_INFO("Resetting search, restoring full destination list"); + + int previousDestIndex = destIndex; + + searchQuery = ""; + updateDestinationSelectionList(); + + // Adjust scrollIndex so previousDestIndex is still visible + int totalEntries = activeChannelIndices.size() + filteredNodes.size(); + this->visibleRows = (displayHeight - FONT_HEIGHT_SMALL * 2) / FONT_HEIGHT_SMALL; + if (this->visibleRows < 1) + this->visibleRows = 1; + int maxScrollIndex = std::max(0, totalEntries - visibleRows); + scrollIndex = std::min(std::max(previousDestIndex - (visibleRows / 2), 0), maxScrollIndex); + + lastUpdateMillis = millis(); + requestFocus(); +} +void CannedMessageModule::updateDestinationSelectionList() +{ + static size_t lastNumMeshNodes = 0; + static String lastSearchQuery = ""; + + size_t numMeshNodes = nodeDB->getNumMeshNodes(); + bool nodesChanged = (numMeshNodes != lastNumMeshNodes); + lastNumMeshNodes = numMeshNodes; + + // Early exit if nothing changed + if (searchQuery == lastSearchQuery && !nodesChanged) + return; + lastSearchQuery = searchQuery; + needsUpdate = false; + + this->filteredNodes.clear(); + this->activeChannelIndices.clear(); + + NodeNum myNodeNum = nodeDB->getNodeNum(); + String lowerSearchQuery = searchQuery; + lowerSearchQuery.toLowerCase(); + + // Preallocate space to reduce reallocation + this->filteredNodes.reserve(numMeshNodes); + + for (size_t i = 0; i < numMeshNodes; ++i) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + if (!node || node->num == myNodeNum) + continue; + + const String &nodeName = node->user.long_name; + + if (searchQuery.length() == 0) { + this->filteredNodes.push_back({node, sinceLastSeen(node)}); + } else { + // Avoid unnecessary lowercase conversion if already matched + String lowerNodeName = nodeName; + lowerNodeName.toLowerCase(); + + if (lowerNodeName.indexOf(lowerSearchQuery) != -1) { + this->filteredNodes.push_back({node, sinceLastSeen(node)}); + } + } + } + + // Populate active channels + std::vector seenChannels; + seenChannels.reserve(channels.getNumChannels()); + for (uint8_t i = 0; i < channels.getNumChannels(); ++i) { + String name = channels.getName(i); + if (name.length() > 0 && std::find(seenChannels.begin(), seenChannels.end(), name) == seenChannels.end()) { + this->activeChannelIndices.push_back(i); + seenChannels.push_back(name); + } + } + + // Sort by favorite, then last heard + std::sort(this->filteredNodes.begin(), this->filteredNodes.end(), [](const NodeEntry &a, const NodeEntry &b) { + if (a.node->is_favorite != b.node->is_favorite) + return a.node->is_favorite > b.node->is_favorite; + return a.lastHeard < b.lastHeard; + }); + scrollIndex = 0; // Show first result at the top + destIndex = 0; // Highlight the first entry + if (nodesChanged && runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) { + LOG_INFO("Nodes changed, forcing UI refresh."); + screen->forceDisplay(); + } +} + +// Returns true if character input is currently allowed (used for search/freetext states) +bool CannedMessageModule::isCharInputAllowed() const +{ + return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION; +} +/** + * Main input event dispatcher for CannedMessageModule. + * Routes keyboard/button/touch input to the correct handler based on the current runState. + * Only one handler (per state) processes each event, eliminating redundancy. + */ 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) + // Block ALL input if an alert banner is active + if (screen && screen->isOverlayBannerShowing()) { 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(USE_VIRTUAL_KEYBOARD) - if (this->currentMessageIndex == 0) { - this->runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + // Tab key: Always allow switching between canned/destination screens + if (event->kbchar == INPUT_BROKER_MSG_TAB && handleTabSwitch(event)) + return 1; - requestFocus(); // Tell Screen::setFrames to move to our module's frame, next time it runs + // Matrix keypad: If matrix key, trigger action select for canned message + if (event->inputEvent == INPUT_BROKER_MATRIXKEY) { + runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; + payload = INPUT_BROKER_MATRIXKEY; + currentMessageIndex = event->kbchar - 1; + lastTouchMillis = millis(); + requestFocus(); + return 1; + } + + // Always normalize navigation/select buttons for further handlers + bool isUp = isUpEvent(event); + bool isDown = isDownEvent(event); + bool isSelect = isSelectEvent(event); + + // Route event to handler for current UI state (no double-handling) + switch (runState) { + // Node/Channel destination selection mode: Handles character search, arrows, select, cancel, backspace + case CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION: + if (handleDestinationSelectionInput(event, isUp, isDown, isSelect)) + return 1; + return 0; // prevent fall-through to selector input + + // Free text input mode: Handles character input, cancel, backspace, select, etc. + case CANNED_MESSAGE_RUN_STATE_FREETEXT: + return handleFreeTextInput(event); // All allowed input for this state + + // If sending, block all input except global/system (handled above) + case CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE: + return 1; + + // If sending, block all input except global/system (handled above) + case CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER: + return handleEmotePickerInput(event); + + case CANNED_MESSAGE_RUN_STATE_INACTIVE: + if (isSelect) { + return 0; // Main button press no longer runs through powerFSM + } + // Let LEFT/RIGHT pass through so frame navigation works + if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_RIGHT) { + break; + } + // Handle UP/DOWN: activate canned message list! + if (event->inputEvent == INPUT_BROKER_UP || event->inputEvent == INPUT_BROKER_DOWN || + event->inputEvent == INPUT_BROKER_ALT_LONG) { + LaunchWithDestination(NODENUM_BROADCAST); + return 1; + } + // Printable char (ASCII) opens free text compose + if (event->kbchar >= 32 && event->kbchar <= 126) { + runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + requestFocus(); UIFrameEvent e; - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen - this->notifyObservers(&e); - - return 0; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + // Immediately process the input in the new state (freetext) + return handleFreeTextInput(event); } -#endif + break; - // 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; - } + // (Other states can be added here as needed) + default: + break; } - 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) && !defined(USE_VIRTUAL_KEYBOARD) - this->freetext = ""; // clear freetext - this->cursor = 0; - this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; -#endif + // If no state handler above processed the event, let the message selector try to handle it + // (Handles up/down/select on canned message list, exit/return) + if (handleMessageSelectorInput(event, isUp, isDown, isSelect)) + return 1; - 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))) { + // Default: event not handled by canned message system, allow others to process + return 0; +} -#if defined(USE_VIRTUAL_KEYBOARD) - if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) { - this->payload = INPUT_BROKER_MSG_LEFT; - } else if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT)) { - this->payload = INPUT_BROKER_MSG_RIGHT; - } -#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 = INPUT_BROKER_MSG_LEFT; - } else if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT)) { - this->payload = INPUT_BROKER_MSG_RIGHT; - } - } else { - // pass the pressed key - this->payload = event->kbchar; - } -#endif +bool CannedMessageModule::isUpEvent(const InputEvent *event) +{ + return event->inputEvent == INPUT_BROKER_UP || + ((runState == CANNED_MESSAGE_RUN_STATE_ACTIVE || runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER || + runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) && + event->inputEvent == INPUT_BROKER_ALT_PRESS); +} +bool CannedMessageModule::isDownEvent(const InputEvent *event) +{ + return event->inputEvent == INPUT_BROKER_DOWN || + ((runState == CANNED_MESSAGE_RUN_STATE_ACTIVE || runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER || + runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) && + event->inputEvent == INPUT_BROKER_USER_PRESS); +} +bool CannedMessageModule::isSelectEvent(const InputEvent *event) +{ + return event->inputEvent == INPUT_BROKER_SELECT; +} - 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; - } +bool CannedMessageModule::handleTabSwitch(const InputEvent *event) +{ + if (event->kbchar != 0x09) + return false; - validEvent = false; // If key is normal than it will be set to true. + runState = (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT + : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION; - // Run modifier key code below, (doesnt inturrupt typing or reset to start screen page) - switch (event->kbchar) { - case INPUT_BROKER_MSG_BRIGHTNESS_UP: // make screen brighter - if (screen) - screen->increaseBrightness(); - LOG_DEBUG("Increase Screen Brightness"); - break; - case INPUT_BROKER_MSG_BRIGHTNESS_DOWN: // make screen dimmer - if (screen) - screen->decreaseBrightness(); - LOG_DEBUG("Decrease Screen Brightness"); - break; - case INPUT_BROKER_MSG_FN_SYMBOL_ON: // draw modifier (function) symbol - if (screen) - screen->setFunctionSymbol("Fn"); - break; - case INPUT_BROKER_MSG_FN_SYMBOL_OFF: // remove modifier (function) symbol - if (screen) - screen->removeFunctionSymbol("Fn"); - break; - // mute (switch off/toggle) external notifications on fn+m - case INPUT_BROKER_MSG_MUTE_TOGGLE: - if (moduleConfig.external_notification.enabled == true) { - if (externalNotificationModule->getMute()) { - externalNotificationModule->setMute(false); - showTemporaryMessage("Notifications \nEnabled"); - if (screen) - screen->removeFunctionSymbol("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->setFunctionSymbol("M"); // add the mute symbol to the bottom right corner - } - } - break; - case INPUT_BROKER_MSG_GPS_TOGGLE: // 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 INPUT_BROKER_MSG_BLUETOOTH_TOGGLE: // toggle Bluetooth on/off - if (config.bluetooth.enabled == true) { - config.bluetooth.enabled = false; - LOG_INFO("User toggled Bluetooth"); - nodeDB->saveToDisk(); - disableBluetooth(); - showTemporaryMessage("Bluetooth OFF"); - } else if (config.bluetooth.enabled == false) { - config.bluetooth.enabled = true; - LOG_INFO("User toggled Bluetooth"); - nodeDB->saveToDisk(); - rebootAtMsec = millis() + 2000; - showTemporaryMessage("Bluetooth ON\nReboot"); - } - break; - case INPUT_BROKER_MSG_SEND_PING: // 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; - case INPUT_BROKER_MSG_DISMISS_FRAME: // fn+del: dismiss screen frames like text or waypoint - // Avoid opening the canned message screen frame - // We're only handling the keypress here by convention, this has nothing to do with canned messages - this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; - // Attempt to close whatever frame is currently shown on display - screen->dismissCurrentFrame(); - return 0; - default: - // pass the pressed key - // LOG_DEBUG("Canned message ANYKEY (%x)", event->kbchar); - this->payload = event->kbchar; - this->lastTouchMillis = millis(); - validEvent = true; - break; - } - if (screen && (event->kbchar != INPUT_BROKER_MSG_FN_SYMBOL_ON)) { - screen->removeFunctionSymbol("Fn"); // remove modifier (function) symbol + destIndex = 0; + scrollIndex = 0; + // RESTORE THIS! + if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) + updateDestinationSelectionList(); + + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + screen->forceDisplay(); + return true; +} + +int CannedMessageModule::handleDestinationSelectionInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect) +{ + // Override isDown and isSelect ONLY for destination selector behavior + if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) { + if (event->inputEvent == INPUT_BROKER_USER_PRESS) { + isDown = true; + } else if (event->inputEvent == INPUT_BROKER_SELECT) { + isSelect = true; } } -#if defined(USE_VIRTUAL_KEYBOARD) - 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 == "⌫") { -#ifndef RAK14014 - this->highlight = keyTapped[0]; -#endif - - 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 == " ") { -#ifndef RAK14014 - this->highlight = keyTapped[0]; -#endif - - 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 != "") { -#ifndef RAK14014 - this->highlight = keyTapped[0]; -#endif - - this->payload = this->shift ? keyTapped[0] : std::tolower(keyTapped[0]); - - validEvent = true; - - this->shift = false; + if (event->kbchar >= 32 && event->kbchar <= 126 && !isUp && !isDown && event->inputEvent != INPUT_BROKER_LEFT && + event->inputEvent != INPUT_BROKER_RIGHT && event->inputEvent != INPUT_BROKER_SELECT) { + this->searchQuery += (char)event->kbchar; + needsUpdate = true; + if ((millis() - lastFilterUpdate) > filterDebounceMs) { + runOnce(); // update filter immediately + lastFilterUpdate = millis(); } - } -#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; + return 1; } - if (validEvent) { - requestFocus(); // Tell Screen::setFrames to move to our module's frame, next time it runs + size_t numMeshNodes = filteredNodes.size(); + int totalEntries = numMeshNodes + activeChannelIndices.size(); + int columns = 1; + int totalRows = totalEntries; + int maxScrollIndex = std::max(0, totalRows - visibleRows); + scrollIndex = clamp(scrollIndex, 0, maxScrollIndex); - // 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 { + // Handle backspace + if (event->inputEvent == INPUT_BROKER_BACK) { + if (searchQuery.length() > 0) { + searchQuery.remove(searchQuery.length() - 1); + needsUpdate = true; runOnce(); } + if (searchQuery.length() == 0) { + resetSearch(); + needsUpdate = false; + } + return 1; + } + + // UP + if (isUp && destIndex > 0) { + destIndex--; + if ((destIndex / columns) < scrollIndex) + scrollIndex = destIndex / columns; + else if ((destIndex / columns) >= (scrollIndex + visibleRows)) + scrollIndex = (destIndex / columns) - visibleRows + 1; + + screen->forceDisplay(); + return 1; + } + + // DOWN + if (isDown && destIndex + 1 < totalEntries) { + destIndex++; + if ((destIndex / columns) >= (scrollIndex + visibleRows)) + scrollIndex = (destIndex / columns) - visibleRows + 1; + + screen->forceDisplay(); + return 1; + } + + // SELECT + if (isSelect) { + if (destIndex < static_cast(activeChannelIndices.size())) { + dest = NODENUM_BROADCAST; + channel = activeChannelIndices[destIndex]; + } else { + int nodeIndex = destIndex - static_cast(activeChannelIndices.size()); + if (nodeIndex >= 0 && nodeIndex < static_cast(filteredNodes.size())) { + const meshtastic_NodeInfoLite *selectedNode = filteredNodes[nodeIndex].node; + if (selectedNode) { + dest = selectedNode->num; + channel = selectedNode->channel; + } + } + } + + runState = returnToCannedList ? CANNED_MESSAGE_RUN_STATE_ACTIVE : CANNED_MESSAGE_RUN_STATE_FREETEXT; + returnToCannedList = false; + screen->forceDisplay(); + return 1; + } + + // CANCEL + if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG) { + runState = returnToCannedList ? CANNED_MESSAGE_RUN_STATE_ACTIVE : CANNED_MESSAGE_RUN_STATE_FREETEXT; + returnToCannedList = false; + searchQuery = ""; + + // UIFrameEvent e; + // e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + // notifyObservers(&e); + screen->forceDisplay(); + return 1; + } + + return 0; +} + +bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect) +{ + // Override isDown and isSelect ONLY for canned message list behavior + if (runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) { + if (event->inputEvent == INPUT_BROKER_USER_PRESS) { + isDown = true; + } else if (event->inputEvent == INPUT_BROKER_SELECT) { + isSelect = true; + } + } + + if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) + return false; + + // === Handle Cancel key: go inactive, clear UI state === + if (runState != CANNED_MESSAGE_RUN_STATE_INACTIVE && + (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG)) { + runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + freetext = ""; + cursor = 0; + payload = 0; + currentMessageIndex = -1; + + // Notify UI that we want to redraw/close this screen + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + screen->forceDisplay(); + return true; + } + + bool handled = false; + + // Handle up/down navigation + if (isUp && messagesCount > 0) { + runState = CANNED_MESSAGE_RUN_STATE_ACTION_UP; + handled = true; + } else if (isDown && messagesCount > 0) { + runState = CANNED_MESSAGE_RUN_STATE_ACTION_DOWN; + handled = true; + } else if (isSelect) { + const char *current = messages[currentMessageIndex]; + + // === [Select Destination] triggers destination selection UI === + if (strcmp(current, "[Select Destination]") == 0) { + returnToCannedList = true; + runState = CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION; + destIndex = 0; + scrollIndex = 0; + updateDestinationSelectionList(); // Make sure list is fresh + screen->forceDisplay(); + return true; + } + + // === [Exit] returns to the main/inactive screen === + if (strcmp(current, "[Exit]") == 0) { + // Set runState to inactive so we return to main UI + runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + currentMessageIndex = -1; + + // Notify UI to regenerate frame set and redraw + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + screen->forceDisplay(); + return true; + } + + // === [Free Text] triggers the free text input (virtual keyboard) === +#if defined(USE_VIRTUAL_KEYBOARD) + if (strcmp(current, "[-- Free Text --]") == 0) { + runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + requestFocus(); + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + return true; + } +#endif + + // Normal canned message selection + if (runState == CANNED_MESSAGE_RUN_STATE_INACTIVE || runState == CANNED_MESSAGE_RUN_STATE_DISABLED) { + } else { + payload = runState; + runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; + handled = true; + } + } + + if (handled) { + requestFocus(); + if (runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) + setIntervalFromNow(0); + else + runOnce(); + } + + return handled; +} +bool CannedMessageModule::handleFreeTextInput(const InputEvent *event) +{ + // Always process only if in FREETEXT mode + if (runState != CANNED_MESSAGE_RUN_STATE_FREETEXT) + return false; + +#if defined(USE_VIRTUAL_KEYBOARD) + // Cancel (dismiss freetext screen) + if (event->inputEvent == INPUT_BROKER_LEFT) { + runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + freetext = ""; + cursor = 0; + payload = 0; + currentMessageIndex = -1; + + // Notify UI that we want to redraw/close this screen + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + screen->forceDisplay(); + return true; + } + // Touch input (virtual keyboard) handling + // Only handle if touch coordinates present (CardKB won't set these) + if (event->touchX != 0 || event->touchY != 0) { + String keyTapped = keyForCoordinates(event->touchX, event->touchY); + bool valid = false; + + if (keyTapped == "⇧") { + highlight = -1; + payload = 0x00; + shift = !shift; + valid = true; + } else if (keyTapped == "⌫") { +#ifndef RAK14014 + highlight = keyTapped[0]; +#endif + payload = 0x08; + shift = false; + valid = true; + } else if (keyTapped == "123" || keyTapped == "ABC") { + highlight = -1; + payload = 0x00; + charSet = (charSet == 0 ? 1 : 0); + valid = true; + } else if (keyTapped == " ") { +#ifndef RAK14014 + highlight = keyTapped[0]; +#endif + payload = keyTapped[0]; + shift = false; + valid = true; + } + // Touch enter/submit + else if (keyTapped == "↵") { + runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; // Send the message! + payload = CANNED_MESSAGE_RUN_STATE_FREETEXT; + currentMessageIndex = -1; + shift = false; + valid = true; + } else if (!(keyTapped == "")) { +#ifndef RAK14014 + highlight = keyTapped[0]; +#endif + payload = shift ? keyTapped[0] : std::tolower(keyTapped[0]); + shift = false; + valid = true; + } + + if (valid) { + lastTouchMillis = millis(); + runOnce(); + payload = 0; + return true; // STOP: We handled a VKB touch + } + } +#endif // USE_VIRTUAL_KEYBOARD + + // ---- All hardware keys fall through to here (CardKB, physical, etc.) ---- + + if (event->kbchar == INPUT_BROKER_MSG_EMOTE_LIST) { + runState = CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER; + requestFocus(); + screen->forceDisplay(); + return true; + } + // Confirm select (Enter) + bool isSelect = isSelectEvent(event); + if (isSelect) { + LOG_DEBUG("[SELECT] handleFreeTextInput: runState=%d, dest=%u, channel=%d, freetext='%s'", (int)runState, dest, channel, + freetext.c_str()); + if (dest == 0) + dest = NODENUM_BROADCAST; + // Defensive: If channel isn't valid, pick the first available channel + if (channel >= channels.getNumChannels()) + channel = 0; + + payload = CANNED_MESSAGE_RUN_STATE_FREETEXT; + currentMessageIndex = -1; + runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; + lastTouchMillis = millis(); + runOnce(); + return true; + } + + // Backspace + if (event->inputEvent == INPUT_BROKER_BACK) { + payload = 0x08; + lastTouchMillis = millis(); + runOnce(); + return true; + } + + // Move cursor left + if (event->inputEvent == INPUT_BROKER_LEFT) { + payload = INPUT_BROKER_LEFT; + lastTouchMillis = millis(); + runOnce(); + return true; + } + // Move cursor right + if (event->inputEvent == INPUT_BROKER_RIGHT) { + payload = INPUT_BROKER_RIGHT; + lastTouchMillis = millis(); + runOnce(); + return true; + } + + // Cancel (dismiss freetext screen) + if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG) { + runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + freetext = ""; + cursor = 0; + payload = 0; + currentMessageIndex = -1; + + // Notify UI that we want to redraw/close this screen + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + screen->forceDisplay(); + return true; + } + + // Tab (switch destination) + if (event->kbchar == INPUT_BROKER_MSG_TAB) { + return handleTabSwitch(event); // Reuse tab logic + } + + // Printable ASCII (add char to draft) + if (event->kbchar >= 32 && event->kbchar <= 126) { + payload = event->kbchar; + lastTouchMillis = millis(); + runOnce(); + return true; + } + + return false; +} + +int CannedMessageModule::handleEmotePickerInput(const InputEvent *event) +{ + int numEmotes = graphics::numEmotes; + + // Override isDown and isSelect ONLY for emote picker behavior + bool isUp = isUpEvent(event); + bool isDown = isDownEvent(event); + bool isSelect = isSelectEvent(event); + if (runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER) { + if (event->inputEvent == INPUT_BROKER_USER_PRESS) { + isDown = true; + } else if (event->inputEvent == INPUT_BROKER_SELECT) { + isSelect = true; + } + } + + // Scroll emote list + if (isUp && emotePickerIndex > 0) { + emotePickerIndex--; + screen->forceDisplay(); + return 1; + } + if (isDown && emotePickerIndex < numEmotes - 1) { + emotePickerIndex++; + screen->forceDisplay(); + return 1; + } + + // Select emote: insert into freetext at cursor and return to freetext + if (isSelect) { + String label = graphics::emotes[emotePickerIndex].label; + String emoteInsert = label; // Just the text label, e.g., ":thumbsup:" + if (cursor == freetext.length()) { + freetext += emoteInsert; + } else { + freetext = freetext.substring(0, cursor) + emoteInsert + freetext.substring(cursor); + } + cursor += emoteInsert.length(); + runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + screen->forceDisplay(); + return 1; + } + + // Cancel returns to freetext + if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG) { + runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + screen->forceDisplay(); + return 1; } return 0; @@ -404,278 +812,196 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event) void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const char *message, bool wantReplies) { + // === Prepare packet === meshtastic_MeshPacket *p = allocDataPacket(); p->to = dest; p->channel = channel; p->want_ack = true; + + // Save destination for ACK/NACK UI fallback + this->lastSentNode = dest; + this->incoming = dest; + + // Copy message payload p->decoded.payload.size = strlen(message); memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); + + // Optionally add bell character if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { - p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character - p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Bell character - p->decoded.payload.size++; + p->decoded.payload.bytes[p->decoded.payload.size++] = 7; // Bell + p->decoded.payload.bytes[p->decoded.payload.size] = '\0'; // Null-terminate } - // 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 + // Mark as waiting for ACK to trigger ACK/NACK screen this->waitingForAck = true; + // Log outgoing message 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. Even if there's no phone connected, this stores the message to match ACKs -} + // Send to mesh and phone (even if no phone connected, to track ACKs) + service->sendToMesh(p, RX_SRC_LOCAL, true); + // === Simulate local message to clear unread UI === + if (screen) { + meshtastic_MeshPacket simulatedPacket = {}; + simulatedPacket.from = 0; // Local device + screen->handleTextMessage(&simulatedPacket); + } + playComboTune(); +} 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)) { + if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION && needsUpdate) { + updateDestinationSelectionList(); + needsUpdate = false; + } + + // If we're in node selection, do nothing except keep alive + if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) { + return INACTIVATE_AFTER_MS; + } + + // Normal module disable/idle handling + if ((this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) || (this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE)) { temporaryMessage = ""; return INT32_MAX; } - // LOG_DEBUG("Check status"); + 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_ACK_NACK_RECEIVED) || + (this->runState == CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION)) { 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 + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; this->currentMessageIndex = -1; - this->freetext = ""; // clear freetext + this->freetext = ""; this->cursor = 0; - -#if !defined(T_WATCH_S3) && !defined(RAK14014) && !defined(SENSECAP_INDICATOR) - 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)) && !Throttle::isWithinTimespanMs(this->lastTouchMillis, INACTIVATE_AFTER_MS)) { - // Reset module - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen + // Reset module on inactivity + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; this->currentMessageIndex = -1; - this->freetext = ""; // clear freetext + this->freetext = ""; this->cursor = 0; - -#if !defined(T_WATCH_S3) && !defined(RAK14014) && !defined(USE_VIRTUAL_KEYBOARD) - 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); + sendText(this->dest, 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 (strcmp(this->messages[this->currentMessageIndex], "[Select Destination]") == 0) { + this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE; + return INT32_MAX; + } 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(USE_VIRTUAL_KEYBOARD) - sendText(this->dest, indexChannels[this->channel], this->messages[this->currentMessageIndex], true); -#else - sendText(this->dest, indexChannels[this->channel], this->messages[this->currentMessageIndex], true); -#endif + sendText(this->dest, this->channel, this->messages[this->currentMessageIndex], true); } this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; } else { - // LOG_DEBUG("Reset message is empty"); this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; } } - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; this->currentMessageIndex = -1; - this->freetext = ""; // clear freetext + this->freetext = ""; this->cursor = 0; - -#if !defined(T_WATCH_S3) && !defined(RAK14014) && !defined(USE_VIRTUAL_KEYBOARD) - 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", this->currentMessageIndex, this->getCurrentMessage()); - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen + } + // Always highlight the first real canned message when entering the message list + else if ((this->runState != CANNED_MESSAGE_RUN_STATE_FREETEXT) && (this->currentMessageIndex == -1)) { + int firstRealMsgIdx = 0; + for (int i = 0; i < this->messagesCount; ++i) { + if (strcmp(this->messages[i], "[Select Destination]") != 0 && strcmp(this->messages[i], "[Exit]") != 0 && + strcmp(this->messages[i], "[---- Free Text ----]") != 0) { + firstRealMsgIdx = i; + break; + } + } + this->currentMessageIndex = firstRealMsgIdx; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; 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->freetext = ""; this->cursor = 0; - -#if !defined(T_WATCH_S3) && !defined(RAK14014) && !defined(USE_VIRTUAL_KEYBOARD) - this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; -#endif - this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE; LOG_DEBUG("MOVE UP (%d):%s", 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->freetext = ""; this->cursor = 0; - -#if !defined(T_WATCH_S3) && !defined(RAK14014) && !defined(USE_VIRTUAL_KEYBOARD) - this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; -#endif - this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE; LOG_DEBUG("MOVE DOWN (%d):%s", this->currentMessageIndex, this->getCurrentMessage()); } } else if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) { switch (this->payload) { - case INPUT_BROKER_MSG_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--; - } + case INPUT_BROKER_LEFT: + if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT && this->cursor > 0) { + this->cursor--; } break; - case INPUT_BROKER_MSG_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++; - } + case INPUT_BROKER_RIGHT: + if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT && 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()); + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + switch (this->payload) { + case 0x08: // backspace + if (this->freetext.length() > 0) { + if (this->cursor > 0) { + 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--; } - 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 INPUT_BROKER_MSG_LEFT: - case INPUT_BROKER_MSG_RIGHT: - // already handled above - break; - // handle fn+s for shutdown - case INPUT_BROKER_MSG_SHUTDOWN: - 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 INPUT_BROKER_MSG_REBOOT: - if (screen) - screen->startAlert("Rebooting..."); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; - runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + case INPUT_BROKER_MSG_TAB: // Tab key: handled by input handler + return 0; + case INPUT_BROKER_LEFT: + case INPUT_BROKER_RIGHT: 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); + // Only insert ASCII printable characters (32–126) + if (this->payload >= 32 && this->payload <= 126) { + if (this->cursor == this->freetext.length()) { + this->freetext += (char)this->payload; + } else { + this->freetext = this->freetext.substring(0, this->cursor) + (char)this->payload + + this->freetext.substring(this->cursor); + } + this->cursor++; + 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->removeFunctionSymbol("Fn"); } - this->lastTouchMillis = millis(); this->notifyObservers(&e); return INACTIVATE_AFTER_MS; @@ -686,7 +1012,6 @@ int32_t CannedMessageModule::runOnce() this->notifyObservers(&e); return INACTIVATE_AFTER_MS; } - return INT32_MAX; } @@ -709,29 +1034,21 @@ const char *CannedMessageModule::getMessageByIndex(int index) const char *CannedMessageModule::getNodeName(NodeNum node) { - if (node == NODENUM_BROADCAST) { + if (node == NODENUM_BROADCAST) return "Broadcast"; - } else { - meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node); - if (info != NULL) { - return info->user.long_name; - } else { - return "Unknown"; - } + + meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node); + if (info && info->has_user && strlen(info->user.long_name) > 0) { + return info->user.long_name; } + + static char fallback[12]; + snprintf(fallback, sizeof(fallback), "0x%08x", node); + return fallback; } 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); } @@ -765,7 +1082,7 @@ void CannedMessageModule::showTemporaryMessage(const String &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; + runState = CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION; // run this loop again in 2 seconds, next iteration will clear the display setIntervalFromNow(2000); } @@ -983,188 +1300,656 @@ bool CannedMessageModule::interceptingKeyboardInput() } } -#if !HAS_TFT +// Draw the node/channel selection screen +void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + requestFocus(); + display->setColor(WHITE); // Always draw cleanly + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + // === Header === + int titleY = 2; + String titleText = "Select Destination"; + titleText += searchQuery.length() > 0 ? " [" + searchQuery + "]" : " [ ]"; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(display->getWidth() / 2, titleY, titleText); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + // === List Items === + int rowYOffset = titleY + (FONT_HEIGHT_SMALL - 4); + int numActiveChannels = this->activeChannelIndices.size(); + int totalEntries = numActiveChannels + this->filteredNodes.size(); + int columns = 1; + this->visibleRows = (display->getHeight() - (titleY + FONT_HEIGHT_SMALL)) / (FONT_HEIGHT_SMALL - 4); + if (this->visibleRows < 1) + this->visibleRows = 1; + + // === Clamp scrolling === + if (scrollIndex > totalEntries / columns) + scrollIndex = totalEntries / columns; + if (scrollIndex < 0) + scrollIndex = 0; + + for (int row = 0; row < visibleRows; row++) { + int itemIndex = scrollIndex + row; + if (itemIndex >= totalEntries) + break; + + int xOffset = 0; + int yOffset = row * (FONT_HEIGHT_SMALL - 4) + rowYOffset; + char entryText[64] = ""; + + // Draw Channels First + if (itemIndex < numActiveChannels) { + uint8_t channelIndex = this->activeChannelIndices[itemIndex]; + snprintf(entryText, sizeof(entryText), "@%s", channels.getName(channelIndex)); + } + // Then Draw Nodes + else { + int nodeIndex = itemIndex - numActiveChannels; + if (nodeIndex >= 0 && nodeIndex < static_cast(this->filteredNodes.size())) { + meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node; + if (node) { + if (node->is_favorite) { + snprintf(entryText, sizeof(entryText), "* %s", node->user.long_name); + } else { + snprintf(entryText, sizeof(entryText), "%s", node->user.long_name); + } + } + } + } + + if (strlen(entryText) == 0 || strcmp(entryText, "Unknown") == 0) + strcpy(entryText, "?"); + + // === Highlight background (if selected) === + if (itemIndex == destIndex) { + int scrollPadding = 8; // Reserve space for scrollbar + display->fillRect(0, yOffset + 2, display->getWidth() - scrollPadding, FONT_HEIGHT_SMALL - 5); + display->setColor(BLACK); + } + + // === Draw entry text === + display->drawString(xOffset + 2, yOffset, entryText); + display->setColor(WHITE); + + // === Draw key icon (after highlight) === + if (itemIndex >= numActiveChannels) { + int nodeIndex = itemIndex - numActiveChannels; + if (nodeIndex >= 0 && nodeIndex < static_cast(this->filteredNodes.size())) { + const meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node; + if (node && hasKeyForNode(node)) { + int iconX = display->getWidth() - key_symbol_width - 15; + int iconY = yOffset + (FONT_HEIGHT_SMALL - key_symbol_height) / 2; + + if (itemIndex == destIndex) { + display->setColor(INVERSE); + } else { + display->setColor(WHITE); + } + display->drawXbm(iconX, iconY, key_symbol_width, key_symbol_height, key_symbol); + } + } + } + } + + // Scrollbar + if (totalEntries > visibleRows) { + int scrollbarHeight = visibleRows * (FONT_HEIGHT_SMALL - 4); + int totalScrollable = totalEntries; + int scrollTrackX = display->getWidth() - 6; + display->drawRect(scrollTrackX, rowYOffset, 4, scrollbarHeight); + int scrollHeight = (scrollbarHeight * visibleRows) / totalScrollable; + int scrollPos = rowYOffset + (scrollbarHeight * scrollIndex) / totalScrollable; + display->fillRect(scrollTrackX, scrollPos, 4, scrollHeight); + } +} + +void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + const int headerFontHeight = FONT_HEIGHT_SMALL; // Make sure this matches your actual small font height + const int headerMargin = 2; // Extra pixels below header + const int labelGap = 6; + const int bitmapGapX = 4; + + // Find max emote height (assume all same, or precalculated) + int maxEmoteHeight = 0; + for (int i = 0; i < graphics::numEmotes; ++i) + if (graphics::emotes[i].height > maxEmoteHeight) + maxEmoteHeight = graphics::emotes[i].height; + + const int rowHeight = maxEmoteHeight + 2; + + // Place header at top, then compute start of emote list + int headerY = y; + int listTop = headerY + headerFontHeight + headerMargin; + + int visibleRows = (display->getHeight() - listTop - 2) / rowHeight; + int numEmotes = graphics::numEmotes; + + // Clamp highlight index + if (emotePickerIndex < 0) + emotePickerIndex = 0; + if (emotePickerIndex >= numEmotes) + emotePickerIndex = numEmotes - 1; + + // Determine which emote is at the top + int topIndex = emotePickerIndex - visibleRows / 2; + if (topIndex < 0) + topIndex = 0; + if (topIndex > numEmotes - visibleRows) + topIndex = std::max(0, numEmotes - visibleRows); + + // Draw header/title + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(display->getWidth() / 2, headerY, "Select Emote"); + + // Draw emote rows + display->setTextAlignment(TEXT_ALIGN_LEFT); + + for (int vis = 0; vis < visibleRows; ++vis) { + int emoteIdx = topIndex + vis; + if (emoteIdx >= numEmotes) + break; + const graphics::Emote &emote = graphics::emotes[emoteIdx]; + int rowY = listTop + vis * rowHeight; + + // Draw highlight box 2px taller than emote (1px margin above and below) + if (emoteIdx == emotePickerIndex) { + display->fillRect(x, rowY, display->getWidth() - 8, emote.height + 2); + display->setColor(BLACK); + } + + // Emote bitmap (left), 1px margin from highlight bar top + int emoteY = rowY + 1; + display->drawXbm(x + bitmapGapX, emoteY, emote.width, emote.height, emote.bitmap); + + // Emote label (right of bitmap) + display->setFont(FONT_MEDIUM); + int labelY = rowY + ((rowHeight - FONT_HEIGHT_MEDIUM) / 2); + display->drawString(x + bitmapGapX + emote.width + labelGap, labelY, emote.label); + + if (emoteIdx == emotePickerIndex) + display->setColor(WHITE); + } + + // Draw scrollbar if needed + if (numEmotes > visibleRows) { + int scrollbarHeight = visibleRows * rowHeight; + int scrollTrackX = display->getWidth() - 6; + display->drawRect(scrollTrackX, listTop, 4, scrollbarHeight); + int scrollBarLen = std::max(6, (scrollbarHeight * visibleRows) / numEmotes); + int scrollBarPos = listTop + (scrollbarHeight * topIndex) / numEmotes; + display->fillRect(scrollTrackX, scrollBarPos, 4, scrollBarLen); + } +} + void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + this->displayHeight = display->getHeight(); // Store display height for later use char buffer[50]; + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + // === Draw temporary message if available === if (temporaryMessage.length() != 0) { requestFocus(); // Tell Screen::setFrames to move to our module's frame LOG_DEBUG("Draw 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 + return; + } -#ifdef USE_EINK - display->setFont(FONT_SMALL); // No chunky text -#else - display->setFont(FONT_MEDIUM); // Chunky text -#endif + // === Emote Picker Screen === + if (this->runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER) { + drawEmotePickerScreen(display, state, x, y); // <-- Call your emote picker drawer here + return; + } - 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)); + // === Destination Selection === + if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) { + drawDestinationSelectionScreen(display, state, x, y); + return; + } - 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 + FONT_HEIGHT_SMALL) { - 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 + // === ACK/NACK Screen === + if (this->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) { + requestFocus(); EINK_ADD_FRAMEFLAG(display, COSMETIC); - - requestFocus(); // Tell Screen::setFrames to move to our module's frame + display->setTextAlignment(TEXT_ALIGN_CENTER); #ifdef USE_EINK - display->setFont(FONT_SMALL); // No chunky text + display->setFont(FONT_SMALL); + int yOffset = y + 10; #else - display->setFont(FONT_MEDIUM); // Chunky text + display->setFont(FONT_MEDIUM); + int yOffset = y + 10; #endif + // --- Delivery Status Message --- + if (this->ack) { + if (this->lastSentNode == NODENUM_BROADCAST) { + snprintf(buffer, sizeof(buffer), "Broadcast Sent to\n%s", channels.getName(this->channel)); + } else if (this->lastAckHopLimit > this->lastAckHopStart) { + snprintf(buffer, sizeof(buffer), "Delivered (%d hops)\nto %s", this->lastAckHopLimit - this->lastAckHopStart, + getNodeName(this->incoming)); + } else { + snprintf(buffer, sizeof(buffer), "Delivered\nto %s", getNodeName(this->incoming)); + } + } else { + snprintf(buffer, sizeof(buffer), "Delivery failed\nto %s", getNodeName(this->incoming)); + } + + // Draw delivery message and compute y-offset after text height + int lineCount = 1; + for (const char *ptr = buffer; *ptr; ptr++) { + if (*ptr == '\n') + lineCount++; + } + + display->drawString(display->getWidth() / 2 + x, yOffset, buffer); + yOffset += lineCount * FONT_HEIGHT_MEDIUM; // only 1 line gap, no extra padding + +#ifndef USE_EINK + // --- SNR + RSSI Compact Line --- + if (this->ack) { + display->setFont(FONT_SMALL); + snprintf(buffer, sizeof(buffer), "SNR: %.1f dB RSSI: %d", this->lastRxSnr, this->lastRxRssi); + display->drawString(display->getWidth() / 2 + x, yOffset, buffer); + } +#endif + + return; + } + + // === Sending Screen === + if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) { + EINK_ADD_FRAMEFLAG(display, COSMETIC); + requestFocus(); +#ifdef USE_EINK + display->setFont(FONT_SMALL); +#else + display->setFont(FONT_MEDIUM); +#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) { + return; + } + + // === Disabled Screen === + if (this->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 + return; + } + + // === Free Text Input Screen === + if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) { + requestFocus(); #if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY) EInkDynamicDisplay *einkDisplay = static_cast(display); - einkDisplay->enableUnlimitedFastMode(); // Enable unlimited fast refresh while typing + einkDisplay->enableUnlimitedFastMode(); #endif - #if defined(USE_VIRTUAL_KEYBOARD) 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) { + + // --- Draw node/channel header at the top --- + drawHeader(display, x, y, buffer); + + // --- Char count right-aligned --- + if (runState != CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) { 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); } + + // --- Draw Free Text input with multi-emote support and proper line wrapping --- 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) { - 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); - if (this->messagesCount > 1) { - display->drawString(0 + x, 0 + y + FONT_HEIGHT_SMALL, cannedMessageModule->getPrevMessage()); - display->drawString(0 + x, 0 + y + FONT_HEIGHT_SMALL * 3, cannedMessageModule->getNextMessage()); + { + int inputY = 0 + y + FONT_HEIGHT_SMALL; + String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor); + + // Tokenize input into (isEmote, token) pairs + std::vector> tokens; + const char *msg = msgWithCursor.c_str(); + int msgLen = strlen(msg); + int pos = 0; + while (pos < msgLen) { + const graphics::Emote *foundEmote = nullptr; + int foundLen = 0; + for (int j = 0; j < graphics::numEmotes; j++) { + const char *label = graphics::emotes[j].label; + int labelLen = strlen(label); + if (labelLen == 0) + continue; + if (strncmp(msg + pos, label, labelLen) == 0) { + if (!foundEmote || labelLen > foundLen) { + foundEmote = &graphics::emotes[j]; + foundLen = labelLen; + } + } } - } else { - 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 - 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 if (messagesCount > 1) { // Only draw others if there are multiple messages - display->drawString(0 + x, 0 + y + FONT_HEIGHT_SMALL * (i + 1), - cannedMessageModule->getMessageByIndex(topMsg + i)); + if (foundEmote) { + tokens.emplace_back(true, String(foundEmote->label)); + pos += foundLen; + } else { + // Find next emote + int nextEmote = msgLen; + for (int j = 0; j < graphics::numEmotes; j++) { + const char *label = graphics::emotes[j].label; + if (!label || !*label) + continue; + const char *found = strstr(msg + pos, label); + if (found && (found - msg) < nextEmote) { + nextEmote = found - msg; + } + } + int textLen = (nextEmote > pos) ? (nextEmote - pos) : (msgLen - pos); + if (textLen > 0) { + tokens.emplace_back(false, String(msg + pos).substring(0, textLen)); + pos += textLen; + } else { + break; } } } + + // ===== Advanced word-wrapping (emotes + text, split by word, wrap by char if needed) ===== + std::vector>> lines; + std::vector> currentLine; + int lineWidth = 0; + int maxWidth = display->getWidth(); + for (auto &token : tokens) { + if (token.first) { + // Emote + int tokenWidth = 0; + for (int j = 0; j < graphics::numEmotes; j++) { + if (token.second == graphics::emotes[j].label) { + tokenWidth = graphics::emotes[j].width + 2; + break; + } + } + if (lineWidth + tokenWidth > maxWidth && !currentLine.empty()) { + lines.push_back(currentLine); + currentLine.clear(); + lineWidth = 0; + } + currentLine.push_back(token); + lineWidth += tokenWidth; + } else { + // Text: split by words and wrap inside word if needed + String text = token.second; + uint16_t pos = 0; + while (pos < text.length()) { + // Find next space (or end) + int spacePos = text.indexOf(' ', pos); + int endPos = (spacePos == -1) ? text.length() : spacePos + 1; // Include space + String word = text.substring(pos, endPos); + int wordWidth = display->getStringWidth(word); + + if (lineWidth + wordWidth > maxWidth && lineWidth > 0) { + lines.push_back(currentLine); + currentLine.clear(); + lineWidth = 0; + } + // If word itself too big, split by character + if (wordWidth > maxWidth) { + uint16_t charPos = 0; + while (charPos < word.length()) { + String oneChar = word.substring(charPos, charPos + 1); + int charWidth = display->getStringWidth(oneChar); + if (lineWidth + charWidth > maxWidth && lineWidth > 0) { + lines.push_back(currentLine); + currentLine.clear(); + lineWidth = 0; + } + currentLine.push_back({false, oneChar}); + lineWidth += charWidth; + charPos++; + } + } else { + currentLine.push_back({false, word}); + lineWidth += wordWidth; + } + pos = endPos; + } + } + } + if (!currentLine.empty()) + lines.push_back(currentLine); + + // Draw lines with emotes + int rowHeight = FONT_HEIGHT_SMALL; + int yLine = inputY; + for (auto &line : lines) { + int nextX = x; + for (auto &token : line) { + if (token.first) { + const graphics::Emote *emote = nullptr; + for (int j = 0; j < graphics::numEmotes; j++) { + if (token.second == graphics::emotes[j].label) { + emote = &graphics::emotes[j]; + break; + } + } + if (emote) { + int emoteYOffset = (rowHeight - emote->height) / 2; + display->drawXbm(nextX, yLine + emoteYOffset, emote->width, emote->height, emote->bitmap); + nextX += emote->width + 2; + } + } else { + display->drawString(nextX, yLine, token.second); + nextX += display->getStringWidth(token.second); + } + } + yLine += rowHeight; + } + } +#endif + return; + } + + // === Canned Messages List === + if (this->messagesCount > 0) { + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + // ====== Precompute per-row heights based on emotes (centered if present) ====== + const int baseRowSpacing = FONT_HEIGHT_SMALL - 4; + + int topMsg; + std::vector rowHeights; + int visibleRows; + + // Draw header (To: ...) + drawHeader(display, x, y, buffer); + + // Shift message list upward by 3 pixels to reduce spacing between header and first message + const int listYOffset = y + FONT_HEIGHT_SMALL - 3; + visibleRows = (display->getHeight() - listYOffset) / baseRowSpacing; + + // Figure out which messages are visible and their needed heights + topMsg = + (messagesCount > visibleRows && currentMessageIndex >= visibleRows - 1) ? currentMessageIndex - visibleRows + 2 : 0; + int countRows = std::min(messagesCount, visibleRows); + + // --- Build per-row max height based on all emotes in line --- + for (int i = 0; i < countRows; i++) { + const char *msg = getMessageByIndex(topMsg + i); + int maxEmoteHeight = 0; + for (int j = 0; j < graphics::numEmotes; j++) { + const char *label = graphics::emotes[j].label; + if (!label || !*label) + continue; + const char *search = msg; + while ((search = strstr(search, label))) { + if (graphics::emotes[j].height > maxEmoteHeight) + maxEmoteHeight = graphics::emotes[j].height; + search += strlen(label); // Advance past this emote + } + } + rowHeights.push_back(std::max(baseRowSpacing, maxEmoteHeight + 2)); + } + + // --- Draw all message rows with multi-emote support --- + int yCursor = listYOffset; + for (int vis = 0; vis < countRows; vis++) { + int msgIdx = topMsg + vis; + int lineY = yCursor; + const char *msg = getMessageByIndex(msgIdx); + int rowHeight = rowHeights[vis]; + bool highlight = (msgIdx == currentMessageIndex); + + // --- Multi-emote tokenization --- + std::vector> tokens; // (isEmote, token) + int pos = 0; + int msgLen = strlen(msg); + while (pos < msgLen) { + const graphics::Emote *foundEmote = nullptr; + int foundLen = 0; + + // Look for any emote label at this pos (prefer longest match) + for (int j = 0; j < graphics::numEmotes; j++) { + const char *label = graphics::emotes[j].label; + int labelLen = strlen(label); + if (labelLen == 0) + continue; + if (strncmp(msg + pos, label, labelLen) == 0) { + if (!foundEmote || labelLen > foundLen) { + foundEmote = &graphics::emotes[j]; + foundLen = labelLen; + } + } + } + if (foundEmote) { + tokens.emplace_back(true, String(foundEmote->label)); + pos += foundLen; + } else { + // Find next emote + int nextEmote = msgLen; + for (int j = 0; j < graphics::numEmotes; j++) { + const char *label = graphics::emotes[j].label; + if (label[0] == 0) + continue; + const char *found = strstr(msg + pos, label); + if (found && (found - msg) < nextEmote) { + nextEmote = found - msg; + } + } + int textLen = (nextEmote > pos) ? (nextEmote - pos) : (msgLen - pos); + if (textLen > 0) { + tokens.emplace_back(false, String(msg + pos).substring(0, textLen)); + pos += textLen; + } else { + break; + } + } + } + // --- End multi-emote tokenization --- + + // Vertically center based on rowHeight + int textYOffset = (rowHeight - FONT_HEIGHT_SMALL) / 2; + +#ifdef USE_EINK + int nextX = x + (highlight ? 12 : 0); + if (highlight) + display->drawString(x + 0, lineY + textYOffset, ">"); +#else + int scrollPadding = 8; + if (highlight) { + display->fillRect(x + 0, lineY, display->getWidth() - scrollPadding, rowHeight); + display->setColor(BLACK); + } + int nextX = x + (highlight ? 2 : 0); +#endif + + // Draw all tokens left to right + for (auto &token : tokens) { + if (token.first) { + // Emote + const graphics::Emote *emote = nullptr; + for (int j = 0; j < graphics::numEmotes; j++) { + if (token.second == graphics::emotes[j].label) { + emote = &graphics::emotes[j]; + break; + } + } + if (emote) { + int emoteYOffset = (rowHeight - emote->height) / 2; + display->drawXbm(nextX, lineY + emoteYOffset, emote->width, emote->height, emote->bitmap); + nextX += emote->width + 2; + } + } else { + // Text + display->drawString(nextX, lineY + textYOffset, token.second); + nextX += display->getStringWidth(token.second); + } + } +#ifndef USE_EINK + if (highlight) + display->setColor(WHITE); +#endif + + yCursor += rowHeight; + } + + // Scrollbar + if (messagesCount > visibleRows) { + int scrollHeight = display->getHeight() - listYOffset; + int scrollTrackX = display->getWidth() - 6; + display->drawRect(scrollTrackX, listYOffset, 4, scrollHeight); + int barHeight = (scrollHeight * visibleRows) / messagesCount; + int scrollPos = listYOffset + (scrollHeight * topMsg) / messagesCount; + display->fillRect(scrollTrackX, scrollPos, 4, barHeight); } } } -#endif //! HAS_TFT 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) { + // Trigger screen refresh for ACK/NACK feedback 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 + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + requestFocus(); this->runState = CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED; - this->incoming = service->getNodenumFromRequestId(mp.decoded.request_id); + + // Decode the routing response 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 + + // Track hop metadata + this->lastAckWasRelayed = (mp.hop_limit != mp.hop_start); + this->lastAckHopStart = mp.hop_start; + this->lastAckHopLimit = mp.hop_limit; + + // Determine ACK status + bool isAck = (decoded.error_reason == meshtastic_Routing_Error_NONE); + bool isFromDest = (mp.from == this->lastSentNode); + bool wasBroadcast = (this->lastSentNode == NODENUM_BROADCAST); + + // Identify the responding node + if (wasBroadcast && mp.from != nodeDB->getNodeNum()) { + this->incoming = mp.from; // Relayed by another node + } else { + this->incoming = this->lastSentNode; // Direct reply + } + + // Final ACK confirmation logic + this->ack = isAck && (wasBroadcast || isFromDest); + + waitingForAck = false; this->notifyObservers(&e); - // run the next time 2 seconds later - setIntervalFromNow(2000); + setIntervalFromNow(3000); // Time to show ACK/NACK screen } } @@ -1206,7 +1991,7 @@ bool CannedMessageModule::saveProtoForModule() */ void CannedMessageModule::installDefaultCannedMessageModuleConfig() { - memset(cannedMessageModuleConfig.messages, 0, sizeof(cannedMessageModuleConfig.messages)); + strncpy(cannedMessageModuleConfig.messages, "Hi|Bye|Yes|No|Ok", sizeof(cannedMessageModuleConfig.messages)); } /** @@ -1276,4 +2061,4 @@ String CannedMessageModule::drawWithCursor(String text, int cursor) return result; } -#endif +#endif \ No newline at end of file diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h index a91933a0f..55a0a1185 100644 --- a/src/modules/CannedMessageModule.h +++ b/src/modules/CannedMessageModule.h @@ -3,27 +3,38 @@ #include "ProtobufModule.h" #include "input/InputBroker.h" +// ============================ +// Enums & Defines +// ============================ + enum cannedMessageModuleRunState { CANNED_MESSAGE_RUN_STATE_DISABLED, CANNED_MESSAGE_RUN_STATE_INACTIVE, CANNED_MESSAGE_RUN_STATE_ACTIVE, - CANNED_MESSAGE_RUN_STATE_FREETEXT, CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE, CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED, - CANNED_MESSAGE_RUN_STATE_MESSAGE, CANNED_MESSAGE_RUN_STATE_ACTION_SELECT, CANNED_MESSAGE_RUN_STATE_ACTION_UP, CANNED_MESSAGE_RUN_STATE_ACTION_DOWN, -}; - -enum cannedMessageDestinationType { - CANNED_MESSAGE_DESTINATION_TYPE_NONE, - CANNED_MESSAGE_DESTINATION_TYPE_NODE, - CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL + CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION, + CANNED_MESSAGE_RUN_STATE_FREETEXT, + CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION, + CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER }; enum CannedMessageModuleIconType { shift, backspace, space, enter }; +#define CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT 50 +#define CANNED_MESSAGE_MODULE_MESSAGES_SIZE 800 + +#ifndef CANNED_MESSAGE_MODULE_ENABLE +#define CANNED_MESSAGE_MODULE_ENABLE 0 +#endif + +// ============================ +// Data Structures +// ============================ + struct Letter { String character; float width; @@ -33,71 +44,72 @@ struct Letter { int rectHeight; }; -#define CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT 50 -/** - * Sum of CannedMessageModuleConfig part sizes. - */ -#define CANNED_MESSAGE_MODULE_MESSAGES_SIZE 800 +struct NodeEntry { + meshtastic_NodeInfoLite *node; + uint32_t lastHeard; +}; -#ifndef CANNED_MESSAGE_MODULE_ENABLE -#define CANNED_MESSAGE_MODULE_ENABLE 0 -#endif +// ============================ +// Main Class +// ============================ class CannedMessageModule : public SinglePortModule, public Observable, private concurrency::OSThread { - CallbackObserver inputObserver = - CallbackObserver(this, &CannedMessageModule::handleInputEvent); - public: CannedMessageModule(); + + void LaunchWithDestination(NodeNum, uint8_t newChannel = 0); + void LaunchFreetextWithDestination(NodeNum, uint8_t newChannel = 0); + + // === Emote Picker navigation === + int emotePickerIndex = 0; // Tracks currently selected emote in the picker + + // === Message navigation === const char *getCurrentMessage(); const char *getPrevMessage(); const char *getNextMessage(); const char *getMessageByIndex(int index); const char *getNodeName(NodeNum node); + + // === State/UI === bool shouldDraw(); bool hasMessages(); - // void eventUp(); - // void eventDown(); - // void eventSelect(); + void showTemporaryMessage(const String &message); + void resetSearch(); + void updateDestinationSelectionList(); + void drawDestinationSelectionScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + bool isCharInputAllowed() const; + String drawWithCursor(String text, int cursor); + // === Emote Picker === + int handleEmotePickerInput(const InputEvent *event); + void drawEmotePickerScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + + // === Admin Handlers === void handleGetCannedMessageModuleMessages(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response); void handleSetCannedMessageModuleMessages(const char *from_msg); - void showTemporaryMessage(const String &message); - - String drawWithCursor(String text, int cursor); - #ifdef RAK14014 cannedMessageModuleRunState getRunState() const { return runState; } #endif - /* - -Override the wantPacket method. We need the Routing Messages to look for ACKs. - */ + // === Packet Interest Filter === virtual bool wantPacket(const meshtastic_MeshPacket *p) override { - if (p->rx_rssi != 0) { - this->lastRxRssi = p->rx_rssi; - } - - if (p->rx_snr > 0) { - this->lastRxSnr = p->rx_snr; - } - - switch (p->decoded.portnum) { - case meshtastic_PortNum_ROUTING_APP: - return waitingForAck; - default: - return false; - } + if (p->rx_rssi != 0) + lastRxRssi = p->rx_rssi; + if (p->rx_snr > 0) + lastRxSnr = p->rx_snr; + return (p->decoded.portnum == meshtastic_PortNum_ROUTING_APP) ? waitingForAck : false; } protected: + // === Thread Entry Point === virtual int32_t runOnce() override; + // === Transmission === void sendText(NodeNum dest, ChannelIndex channel, const char *message, bool wantReplies); - + void drawHeader(OLEDDisplay *display, int16_t x, int16_t y, char *buffer); int splitConfiguredMessages(); int getNextIndex(); int getPrevIndex(); @@ -105,58 +117,87 @@ class CannedMessageModule : public SinglePortModule, public ObservableshouldDraw(); } + virtual bool wantUIFrame() override { return shouldDraw(); } virtual Observable *getUIFrameObservable() override { return this; } virtual bool interceptingKeyboardInput() override; -#if !HAS_TFT virtual void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) override; -#endif virtual AdminMessageHandleResult handleAdminMessageForModule(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, meshtastic_AdminMessage *response) override; - /** Called to handle a particular incoming message - * @return ProcessMessage::STOP if you've guaranteed you've handled this message and no other handlers should be considered - * for it - */ virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; void loadProtoForModule(); bool saveProtoForModule(); - void installDefaultCannedMessageModuleConfig(); - int currentMessageIndex = -1; - cannedMessageModuleRunState runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; - char payload = 0x00; - unsigned int cursor = 0; - String freetext = ""; // Text Buffer for Freetext Editor - NodeNum dest = NODENUM_BROADCAST; - ChannelIndex channel = 0; - cannedMessageDestinationType destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; - uint8_t numChannels = 0; - ChannelIndex indexChannels[MAX_NUM_CHANNELS] = {0}; - NodeNum incoming = NODENUM_BROADCAST; - bool ack = false; // True means ACK, false means NAK (error_reason != NONE) - bool waitingForAck = false; // Are currently interested in routing packets? - float lastRxSnr = 0; - int32_t lastRxRssi = 0; + private: + // === Input Observers === + CallbackObserver inputObserver = + CallbackObserver(this, &CannedMessageModule::handleInputEvent); + // === Display and UI === + int displayHeight = 64; + int destIndex = 0; + int scrollIndex = 0; + int visibleRows = 0; + bool needsUpdate = true; + unsigned long lastUpdateMillis = 0; + String searchQuery; + String freetext; + String temporaryMessage; + + // === Message Storage === char messageStore[CANNED_MESSAGE_MODULE_MESSAGES_SIZE + 1]; char *messages[CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT]; int messagesCount = 0; + int currentMessageIndex = -1; + + // === Routing & Acknowledgment === + NodeNum dest = NODENUM_BROADCAST; // Destination node for outgoing messages (default: broadcast) + NodeNum incoming = NODENUM_BROADCAST; // Source node from which last ACK/NACK was received + NodeNum lastSentNode = 0; // Tracks the most recent node we sent a message to (for UI display) + ChannelIndex channel = 0; // Channel index used when sending a message + + bool ack = false; // True = ACK received, False = NACK or failed + bool waitingForAck = false; // True if we're expecting an ACK and should monitor routing packets + bool lastAckWasRelayed = false; // True if the ACK was relayed through intermediate nodes + uint8_t lastAckHopStart = 0; // Hop start value from the received ACK packet + uint8_t lastAckHopLimit = 0; // Hop limit value from the received ACK packet + + float lastRxSnr = 0; // SNR from last received ACK (used for diagnostics/UI) + int32_t lastRxRssi = 0; // RSSI from last received ACK (used for diagnostics/UI) + + // === State Tracking === + cannedMessageModuleRunState runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + char highlight = 0x00; + char payload = 0x00; + unsigned int cursor = 0; unsigned long lastTouchMillis = 0; - String temporaryMessage; + uint32_t lastFilterUpdate = 0; + static constexpr uint32_t filterDebounceMs = 30; + std::vector activeChannelIndices; + std::vector filteredNodes; + +#if defined(USE_VIRTUAL_KEYBOARD) + bool shift = false; + int charSet = 0; // 0=ABC, 1=123 +#endif + + bool isUpEvent(const InputEvent *event); + bool isDownEvent(const InputEvent *event); + bool isSelectEvent(const InputEvent *event); + bool handleTabSwitch(const InputEvent *event); + int handleDestinationSelectionInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect); + bool handleMessageSelectorInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect); + bool handleFreeTextInput(const InputEvent *event); #if defined(USE_VIRTUAL_KEYBOARD) Letter keyboard[2][4][10] = {{{{"Q", 20, 0, 0, 0, 0}, diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 615c3590b..956508ce5 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -188,7 +188,7 @@ int32_t ExternalNotificationModule::runOnce() // Play RTTTL over i2s audio interface if enabled as buzzer #ifdef HAS_I2S - if (moduleConfig.external_notification.use_i2s_as_buzzer) { + if (moduleConfig.external_notification.use_i2s_as_buzzer && canBuzz()) { if (audioThread->isPlaying()) { // Continue playing } else if (isNagging && (nagCycleCutoff >= millis())) { @@ -197,7 +197,7 @@ int32_t ExternalNotificationModule::runOnce() } #endif // now let the PWM buzzer play - if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio) { + if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz()) { if (rtttl::isPlaying()) { rtttl::play(); } else if (isNagging && (nagCycleCutoff >= millis())) { @@ -210,6 +210,18 @@ int32_t ExternalNotificationModule::runOnce() } } +/** + * Based on buzzer mode, return true if we can buzz. + */ +bool ExternalNotificationModule::canBuzz() +{ + if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED && + config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_SYSTEM_ONLY) { + return true; + } + return false; +} + bool ExternalNotificationModule::wantPacket(const meshtastic_MeshPacket *p) { return MeshService::isTextPayload(p); @@ -344,6 +356,9 @@ ExternalNotificationModule::ExternalNotificationModule() // moduleConfig.external_notification.alert_message_buzzer = true; if (moduleConfig.external_notification.enabled) { + if (inputBroker) // put our callback in the inputObserver list + inputObserver.observe(inputBroker); + if (nodeDB->loadProto(rtttlConfigFile, meshtastic_RTTTLConfig_size, sizeof(meshtastic_RTTTLConfig), &meshtastic_RTTTLConfig_msg, &rtttlConfig) != LoadFileResult::LOAD_SUCCESS) { memset(rtttlConfig.ringtone, 0, sizeof(rtttlConfig.ringtone)); @@ -370,7 +385,7 @@ ExternalNotificationModule::ExternalNotificationModule() setExternalState(1, false); externalTurnedOn[1] = 0; } - if (moduleConfig.external_notification.output_buzzer) { + if (moduleConfig.external_notification.output_buzzer && canBuzz()) { if (!moduleConfig.external_notification.use_pwm) { LOG_INFO("Use Pin %i for buzzer", moduleConfig.external_notification.output_buzzer); pinMode(moduleConfig.external_notification.output_buzzer, OUTPUT); @@ -460,7 +475,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP } } - if (moduleConfig.external_notification.alert_bell_buzzer) { + if (moduleConfig.external_notification.alert_bell_buzzer && canBuzz()) { if (containsBell) { LOG_INFO("externalNotificationModule - Notification Bell (Buzzer)"); isNagging = true; @@ -589,4 +604,13 @@ void ExternalNotificationModule::handleSetRingtone(const char *from_msg) if (changed) { nodeDB->saveProto(rtttlConfigFile, meshtastic_RTTTLConfig_size, &meshtastic_RTTTLConfig_msg, &rtttlConfig); } +} + +int ExternalNotificationModule::handleInputEvent(const InputEvent *event) +{ + if (nagCycleCutoff != UINT32_MAX) { + stopNow(); + return 1; + } + return 0; } \ No newline at end of file diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index 85950464d..19cf9eb7b 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -3,6 +3,8 @@ #include "SinglePortModule.h" #include "concurrency/OSThread.h" #include "configuration.h" +#include "input/InputBroker.h" + #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !defined(CONFIG_IDF_TARGET_ESP32C6) #include #else @@ -27,11 +29,15 @@ class rtttl */ class ExternalNotificationModule : public SinglePortModule, private concurrency::OSThread { + CallbackObserver inputObserver = + CallbackObserver(this, &ExternalNotificationModule::handleInputEvent); uint32_t output = 0; public: ExternalNotificationModule(); + int handleInputEvent(const InputEvent *arg); + uint32_t nagCycleCutoff = 1; void setExternalState(uint8_t index = 0, bool on = false); @@ -40,6 +46,7 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: void setMute(bool mute) { isMuted = mute; } bool getMute() { return isMuted; } + bool canBuzz(); bool nagging(); void stopNow(); diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp new file mode 100644 index 000000000..f5a9f2359 --- /dev/null +++ b/src/modules/KeyVerificationModule.cpp @@ -0,0 +1,310 @@ +#if !MESHTASTIC_EXCLUDE_PKI +#include "KeyVerificationModule.h" +#include "MeshService.h" +#include "RTC.h" +#include "main.h" +#include "modules/AdminModule.h" +#include + +KeyVerificationModule *keyVerificationModule; + +KeyVerificationModule::KeyVerificationModule() + : ProtobufModule("KeyVerification", meshtastic_PortNum_KEY_VERIFICATION_APP, &meshtastic_KeyVerification_msg) +{ + ourPortNum = meshtastic_PortNum_KEY_VERIFICATION_APP; +} + +AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(const meshtastic_MeshPacket &mp, + meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + updateState(); + if (request->which_payload_variant == meshtastic_AdminMessage_key_verification_tag && mp.from == 0) { + LOG_WARN("Handling Key Verification Admin Message type %u", request->key_verification.message_type); + + if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_INITIATE_VERIFICATION && + currentState == KEY_VERIFICATION_IDLE) { + sendInitialRequest(request->key_verification.remote_nodenum); + + } else if (request->key_verification.message_type == + meshtastic_KeyVerificationAdmin_MessageType_PROVIDE_SECURITY_NUMBER && + request->key_verification.has_security_number && currentState == KEY_VERIFICATION_SENDER_AWAITING_NUMBER && + request->key_verification.nonce == currentNonce) { + processSecurityNumber(request->key_verification.security_number); + + } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_VERIFY && + request->key_verification.nonce == currentNonce) { + auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); + remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + resetToIdle(); + } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_NOT_VERIFY) { + resetToIdle(); + } + return AdminMessageHandleResult::HANDLED; + } + return AdminMessageHandleResult::NOT_HANDLED; +} + +bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r) +{ + updateState(); + if (mp.pki_encrypted == false) + return false; + if (mp.from != currentRemoteNode) // because the inital connection request is handled in allocReply() + return false; + if (currentState == KEY_VERIFICATION_IDLE) { + return false; // if we're idle, the only acceptable message is an init, which should be handled by allocReply() + + } else if (currentState == KEY_VERIFICATION_SENDER_HAS_INITIATED && r->nonce == currentNonce && r->hash2.size == 32 && + r->hash1.size == 0) { + memcpy(hash2, r->hash2.bytes, 32); + if (screen) + screen->showOverlayBanner("Enter Security Number", 30000); + + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + cn->level = meshtastic_LogRecord_Level_WARNING; + sprintf(cn->message, "Enter Security Number for Key Verification"); + cn->which_payload_variant = meshtastic_ClientNotification_key_verification_number_request_tag; + cn->payload_variant.key_verification_number_request.nonce = currentNonce; + strncpy(cn->payload_variant.key_verification_number_request.remote_longname, // should really check for nulls, etc + nodeDB->getMeshNode(currentRemoteNode)->user.long_name, + sizeof(cn->payload_variant.key_verification_number_request.remote_longname)); + service->sendClientNotification(cn); + LOG_INFO("Received hash2"); + currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER; + return true; + + } else if (currentState == KEY_VERIFICATION_RECEIVER_AWAITING_HASH1 && r->hash1.size == 32 && r->nonce == currentNonce) { + if (memcmp(hash1, r->hash1.bytes, 32) == 0) { + memset(message, 0, sizeof(message)); + sprintf(message, "Verification: \n"); + generateVerificationCode(message + 15); + sprintf(message + 24, "\nACCEPT\nREJECT"); + LOG_INFO("Hash1 matches!"); + if (screen) { + screen->showOverlayBanner(message, 30000, 2, [=](int selected) { + if (selected == 0) { + auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); + remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + } + }); + } + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + cn->level = meshtastic_LogRecord_Level_WARNING; + sprintf(cn->message, "Final confirmation for incoming manual key verification %s", message); + cn->which_payload_variant = meshtastic_ClientNotification_key_verification_final_tag; + cn->payload_variant.key_verification_final.nonce = currentNonce; + strncpy(cn->payload_variant.key_verification_final.remote_longname, // should really check for nulls, etc + nodeDB->getMeshNode(currentRemoteNode)->user.long_name, + sizeof(cn->payload_variant.key_verification_final.remote_longname)); + cn->payload_variant.key_verification_final.isSender = false; + service->sendClientNotification(cn); + + currentState = KEY_VERIFICATION_RECEIVER_AWAITING_USER; + return true; + } + } + return false; +} + +bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode) +{ + LOG_DEBUG("keyVerification start"); + // generate nonce + updateState(); + if (currentState != KEY_VERIFICATION_IDLE) { + return false; + } + currentNonce = random(); + currentNonceTimestamp = getTime(); + currentRemoteNode = remoteNode; + meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero; + KeyVerification.nonce = currentNonce; + KeyVerification.hash2.size = 0; + KeyVerification.hash1.size = 0; + meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification); + p->to = remoteNode; + p->channel = 0; + p->pki_encrypted = true; + p->decoded.want_response = true; + p->priority = meshtastic_MeshPacket_Priority_HIGH; + service->sendToMesh(p, RX_SRC_LOCAL, true); + + currentState = KEY_VERIFICATION_SENDER_HAS_INITIATED; + return true; +} + +meshtastic_MeshPacket *KeyVerificationModule::allocReply() +{ + SHA256 hash; + NodeNum ourNodeNum = nodeDB->getNodeNum(); + updateState(); + if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period + LOG_WARN("Key Verification requested, but already in a request"); + return nullptr; + } else if (!currentRequest->pki_encrypted) { + LOG_WARN("Key Verification requested, but not in a PKI packet"); + return nullptr; + } + currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1; + + auto req = *currentRequest; + const auto &p = req.decoded; + meshtastic_KeyVerification scratch; + meshtastic_KeyVerification response; + meshtastic_MeshPacket *responsePacket = nullptr; + pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_KeyVerification_msg, &scratch); + + currentNonce = scratch.nonce; + response.nonce = scratch.nonce; + currentRemoteNode = req.from; + currentNonceTimestamp = getTime(); + currentSecurityNumber = random(1, 999999); + + // generate hash1 + hash.reset(); + hash.update(¤tSecurityNumber, sizeof(currentSecurityNumber)); + hash.update(¤tNonce, sizeof(currentNonce)); + hash.update(¤tRemoteNode, sizeof(currentRemoteNode)); + hash.update(&ourNodeNum, sizeof(ourNodeNum)); + hash.update(currentRequest->public_key.bytes, currentRequest->public_key.size); + hash.update(owner.public_key.bytes, owner.public_key.size); + hash.finalize(hash1, 32); + + // generate hash2 + hash.reset(); + hash.update(¤tNonce, sizeof(currentNonce)); + hash.update(hash1, 32); + hash.finalize(hash2, 32); + response.hash1.size = 0; + response.hash2.size = 32; + memcpy(response.hash2.bytes, hash2, 32); + + responsePacket = allocDataProtobuf(response); + + responsePacket->pki_encrypted = true; + if (screen) { + snprintf(message, 25, "Security Number \n%03u %03u", currentSecurityNumber / 1000, currentSecurityNumber % 1000); + screen->showOverlayBanner(message, 30000); + LOG_WARN("%s", message); + } + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + cn->level = meshtastic_LogRecord_Level_WARNING; + sprintf(cn->message, "Incoming Key Verification.\nSecurity Number\n%03u %03u", currentSecurityNumber / 1000, + currentSecurityNumber % 1000); + cn->which_payload_variant = meshtastic_ClientNotification_key_verification_number_inform_tag; + cn->payload_variant.key_verification_number_inform.nonce = currentNonce; + strncpy(cn->payload_variant.key_verification_number_inform.remote_longname, // should really check for nulls, etc + nodeDB->getMeshNode(currentRemoteNode)->user.long_name, + sizeof(cn->payload_variant.key_verification_number_inform.remote_longname)); + cn->payload_variant.key_verification_number_inform.security_number = currentSecurityNumber; + service->sendClientNotification(cn); + LOG_WARN("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce); + return responsePacket; +} + +void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) +{ + SHA256 hash; + NodeNum ourNodeNum = nodeDB->getNodeNum(); + uint8_t scratch_hash[32] = {0}; + LOG_WARN("received security number: %u", incomingNumber); + meshtastic_NodeInfoLite *remoteNodePtr = nullptr; + remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); + if (remoteNodePtr == nullptr || !remoteNodePtr->has_user || remoteNodePtr->user.public_key.size != 32) { + currentState = KEY_VERIFICATION_IDLE; + return; // should we throw an error here? + } + LOG_WARN("hashing "); + // calculate hash1 + hash.reset(); + hash.update(&incomingNumber, sizeof(incomingNumber)); + hash.update(¤tNonce, sizeof(currentNonce)); + hash.update(&ourNodeNum, sizeof(ourNodeNum)); + hash.update(¤tRemoteNode, sizeof(currentRemoteNode)); + hash.update(owner.public_key.bytes, owner.public_key.size); + + hash.update(remoteNodePtr->user.public_key.bytes, remoteNodePtr->user.public_key.size); + hash.finalize(hash1, 32); + + hash.reset(); + hash.update(¤tNonce, sizeof(currentNonce)); + hash.update(hash1, 32); + hash.finalize(scratch_hash, 32); + + if (memcmp(scratch_hash, hash2, 32) != 0) { + LOG_WARN("Hash2 did not match"); + return; // should probably throw an error of some sort + } + currentSecurityNumber = incomingNumber; + + meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero; + KeyVerification.nonce = currentNonce; + KeyVerification.hash2.size = 0; + KeyVerification.hash1.size = 32; + memcpy(KeyVerification.hash1.bytes, hash1, 32); + meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification); + p->to = currentRemoteNode; + p->channel = 0; + p->pki_encrypted = true; + p->decoded.want_response = true; + p->priority = meshtastic_MeshPacket_Priority_HIGH; + service->sendToMesh(p, RX_SRC_LOCAL, true); + currentState = KEY_VERIFICATION_SENDER_AWAITING_USER; + memset(message, 0, sizeof(message)); + sprintf(message, "Verification: \n"); + generateVerificationCode(message + 15); // send the toPhone packet + if (screen) { + screen->showOverlayBanner(message, 30000); + } + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + cn->level = meshtastic_LogRecord_Level_WARNING; + sprintf(cn->message, "Final confirmation for outgoing manual key verification %s", message); + cn->which_payload_variant = meshtastic_ClientNotification_key_verification_final_tag; + cn->payload_variant.key_verification_final.nonce = currentNonce; + strncpy(cn->payload_variant.key_verification_final.remote_longname, // should really check for nulls, etc + nodeDB->getMeshNode(currentRemoteNode)->user.long_name, + sizeof(cn->payload_variant.key_verification_final.remote_longname)); + cn->payload_variant.key_verification_final.isSender = true; + service->sendClientNotification(cn); + LOG_INFO(message); + + return; +} + +void KeyVerificationModule::updateState() +{ + if (currentState != KEY_VERIFICATION_IDLE) { + // check for the 30 second timeout + if (currentNonceTimestamp < getTime() - 60) { + resetToIdle(); + } else { + currentNonceTimestamp = getTime(); + } + } +} + +void KeyVerificationModule::resetToIdle() +{ + memset(hash1, 0, 32); + memset(hash2, 0, 32); + currentNonce = 0; + currentNonceTimestamp = 0; + currentSecurityNumber = 0; + currentRemoteNode = 0; + currentState = KEY_VERIFICATION_IDLE; +} + +void KeyVerificationModule::generateVerificationCode(char *readableCode) +{ + for (int i = 0; i < 4; i++) { + // drop the two highest significance bits, then encode as a base64 + readableCode[i] = (hash1[i] >> 2) + 48; // not a standardized base64, but workable and avoids having a dictionary. + } + readableCode[4] = ' '; + for (int i = 5; i < 9; i++) { + // drop the two highest significance bits, then encode as a base64 + readableCode[i] = (hash1[i] >> 2) + 48; // not a standardized base64, but workable and avoids having a dictionary. + } +} +#endif \ No newline at end of file diff --git a/src/modules/KeyVerificationModule.h b/src/modules/KeyVerificationModule.h new file mode 100644 index 000000000..f659e961a --- /dev/null +++ b/src/modules/KeyVerificationModule.h @@ -0,0 +1,64 @@ +#pragma once + +#include "ProtobufModule.h" +#include "SinglePortModule.h" + +enum KeyVerificationState { + KEY_VERIFICATION_IDLE, + KEY_VERIFICATION_SENDER_HAS_INITIATED, + KEY_VERIFICATION_SENDER_AWAITING_NUMBER, + KEY_VERIFICATION_SENDER_AWAITING_USER, + KEY_VERIFICATION_RECEIVER_AWAITING_USER, + KEY_VERIFICATION_RECEIVER_AWAITING_HASH1, +}; + +class KeyVerificationModule : public ProtobufModule //, private concurrency::OSThread // +{ + // CallbackObserver nodeStatusObserver = + // CallbackObserver(this, &KeyVerificationModule::handleStatusUpdate); + + public: + KeyVerificationModule(); + /* : concurrency::OSThread("KeyVerification"), + ProtobufModule("KeyVerification", meshtastic_PortNum_KEY_VERIFICATION_APP, &meshtastic_KeyVerification_msg) + { + nodeStatusObserver.observe(&nodeStatus->onNewStatus); + setIntervalFromNow(setStartDelay()); // Wait until NodeInfo is sent + }*/ + virtual bool wantUIFrame() { return false; }; + bool sendInitialRequest(NodeNum remoteNode); + + protected: + /* Called to handle a particular incoming message + @return true if you've guaranteed you've handled this message and no other handlers should be considered for it + */ + virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *p); + // virtual meshtastic_MeshPacket *allocReply() override; + + // rather than add to the craziness that is the admin module, just handle those requests here. + virtual AdminMessageHandleResult handleAdminMessageForModule(const meshtastic_MeshPacket &mp, + meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; + /* + * Send our Telemetry into the mesh + */ + bool sendMetrics(); + virtual meshtastic_MeshPacket *allocReply() override; + + private: + uint64_t currentNonce = 0; + uint32_t currentNonceTimestamp = 0; + NodeNum currentRemoteNode = 0; + uint32_t currentSecurityNumber = 0; + KeyVerificationState currentState = KEY_VERIFICATION_IDLE; + uint8_t hash1[32] = {0}; // + uint8_t hash2[32] = {0}; // + char message[40] = {0}; + + void processSecurityNumber(uint32_t); + void updateState(); // check the timeouts and maybe reset the state to idle + void resetToIdle(); // Zero out module state + void generateVerificationCode(char *); // fills char with the user readable verification code +}; + +extern KeyVerificationModule *keyVerificationModule; \ No newline at end of file diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index fac2ca976..783c08b9f 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -1,17 +1,21 @@ #include "configuration.h" #if !MESHTASTIC_EXCLUDE_INPUTBROKER +#include "buzz/BuzzerFeedbackThread.h" #include "input/ExpressLRSFiveWay.h" #include "input/InputBroker.h" #include "input/RotaryEncoderInterruptImpl1.h" -#include "input/ScanAndSelect.h" #include "input/SerialKeyboardImpl.h" #include "input/TrackballInterruptImpl1.h" #include "input/UpDownInterruptImpl1.h" +#include "modules/SystemCommandsModule.h" #if !MESHTASTIC_EXCLUDE_I2C #include "input/cardKbI2cImpl.h" #endif #include "input/kbMatrixImpl.h" #endif +#if !MESHTASTIC_EXCLUDE_PKI +#include "KeyVerificationModule.h" +#endif #if !MESHTASTIC_EXCLUDE_ADMIN #include "modules/AdminModule.h" #endif @@ -62,6 +66,7 @@ #include "modules/Telemetry/AirQualityTelemetry.h" #include "modules/Telemetry/EnvironmentTelemetry.h" #include "modules/Telemetry/HealthTelemetry.h" +#include "modules/Telemetry/Sensor/TelemetrySensor.h" #endif #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_POWER_TELEMETRY #include "modules/Telemetry/PowerTelemetry.h" @@ -104,7 +109,11 @@ void setupModules() { if (config.device.role != meshtastic_Config_DeviceConfig_Role_REPEATER) { #if (HAS_BUTTON || ARCH_PORTDUINO) && !MESHTASTIC_EXCLUDE_INPUTBROKER - inputBroker = new InputBroker(); + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + inputBroker = new InputBroker(); + systemCommandsModule = new SystemCommandsModule(); + buzzerFeedbackThread = new BuzzerFeedbackThread(); + } #endif #if !MESHTASTIC_EXCLUDE_ADMIN adminModule = new AdminModule(); @@ -133,7 +142,9 @@ void setupModules() #if !MESHTASTIC_EXCLUDE_ATAK atakPluginModule = new AtakPluginModule(); #endif - +#if !MESHTASTIC_EXCLUDE_PKI + keyVerificationModule = new KeyVerificationModule(); +#endif #if !MESHTASTIC_EXCLUDE_DROPZONE dropzoneModule = new DropzoneModule(); #endif @@ -152,50 +163,49 @@ void setupModules() // Example: Put your module here // new ReplyModule(); #if (HAS_BUTTON || ARCH_PORTDUINO) && !MESHTASTIC_EXCLUDE_INPUTBROKER - rotaryEncoderInterruptImpl1 = new RotaryEncoderInterruptImpl1(); - if (!rotaryEncoderInterruptImpl1->init()) { - delete rotaryEncoderInterruptImpl1; - rotaryEncoderInterruptImpl1 = nullptr; - } - upDownInterruptImpl1 = new UpDownInterruptImpl1(); - if (!upDownInterruptImpl1->init()) { - delete upDownInterruptImpl1; - upDownInterruptImpl1 = nullptr; - } -#if HAS_SCREEN - // In order to have the user button dismiss the canned message frame, this class lightly interacts with the Screen class - scanAndSelectInput = new ScanAndSelectInput(); - if (!scanAndSelectInput->init()) { - delete scanAndSelectInput; - scanAndSelectInput = nullptr; - } -#endif - - cardKbI2cImpl = new CardKbI2cImpl(); - cardKbI2cImpl->init(); + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + rotaryEncoderInterruptImpl1 = new RotaryEncoderInterruptImpl1(); + if (!rotaryEncoderInterruptImpl1->init()) { + delete rotaryEncoderInterruptImpl1; + rotaryEncoderInterruptImpl1 = nullptr; + } + upDownInterruptImpl1 = new UpDownInterruptImpl1(); + if (!upDownInterruptImpl1->init()) { + delete upDownInterruptImpl1; + upDownInterruptImpl1 = nullptr; + } + cardKbI2cImpl = new CardKbI2cImpl(); + cardKbI2cImpl->init(); #ifdef INPUTBROKER_MATRIX_TYPE - kbMatrixImpl = new KbMatrixImpl(); - kbMatrixImpl->init(); + kbMatrixImpl = new KbMatrixImpl(); + kbMatrixImpl->init(); #endif // INPUTBROKER_MATRIX_TYPE #ifdef INPUTBROKER_SERIAL_TYPE - aSerialKeyboardImpl = new SerialKeyboardImpl(); - aSerialKeyboardImpl->init(); + aSerialKeyboardImpl = new SerialKeyboardImpl(); + aSerialKeyboardImpl->init(); #endif // INPUTBROKER_MATRIX_TYPE + } #endif // HAS_BUTTON -#if ARCH_PORTDUINO && !HAS_TFT - aLinuxInputImpl = new LinuxInputImpl(); - aLinuxInputImpl->init(); +#if ARCH_PORTDUINO + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + aLinuxInputImpl = new LinuxInputImpl(); + aLinuxInputImpl->init(); + } #endif -#if HAS_TRACKBALL && !MESHTASTIC_EXCLUDE_INPUTBROKER - trackballInterruptImpl1 = new TrackballInterruptImpl1(); - trackballInterruptImpl1->init(); +#if !MESHTASTIC_EXCLUDE_INPUTBROKER + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + trackballInterruptImpl1 = new TrackballInterruptImpl1(); + trackballInterruptImpl1->init(TB_DOWN, TB_UP, TB_LEFT, TB_RIGHT, TB_PRESS); + } #endif #ifdef INPUTBROKER_EXPRESSLRSFIVEWAY_TYPE expressLRSFiveWayInput = new ExpressLRSFiveWay(); #endif #if HAS_SCREEN && !MESHTASTIC_EXCLUDE_CANNEDMESSAGES - cannedMessageModule = new CannedMessageModule(); + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + cannedMessageModule = new CannedMessageModule(); + } #endif #if ARCH_PORTDUINO new HostMetricsModule(); @@ -221,7 +231,9 @@ void setupModules() #if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040)) && !defined(CONFIG_IDF_TARGET_ESP32S2) && \ !defined(CONFIG_IDF_TARGET_ESP32C3) #if !MESHTASTIC_EXCLUDE_SERIAL - new SerialModule(); + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + new SerialModule(); + } #endif #endif #ifdef ARCH_ESP32 diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index e072fcb0f..cf9940e25 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -21,13 +21,6 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes bool wasBroadcast = isBroadcast(mp.to); - // Show new nodes on LCD screen - if (wasBroadcast) { - String lcd = String("Joined: ") + p.long_name + "\n"; - if (screen) - screen->print(lcd.c_str()); - } - // if user has changed while packet was not for us, inform phone if (hasChanged && !wasBroadcast && !isToUs(&mp)) service->sendToPhone(packetPool.allocCopy(mp)); diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index c34c725c0..93c65ecc1 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -332,7 +332,13 @@ void PositionModule::sendOurPosition() // If we changed channels, ask everyone else for their latest info LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); - sendOurPosition(NODENUM_BROADCAST, requestReplies); + for (uint8_t channelNum = 0; channelNum < 8; channelNum++) { + if (channels.getByIndex(channelNum).settings.has_module_settings && + channels.getByIndex(channelNum).settings.module_settings.position_precision != 0) { + sendOurPosition(NODENUM_BROADCAST, requestReplies, channelNum); + return; + } + } } void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t channel) @@ -344,11 +350,6 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha // Set's the class precision value for this particular packet if (channels.getByIndex(channel).settings.has_module_settings) { precision = channels.getByIndex(channel).settings.module_settings.position_precision; - } else if (channels.getByIndex(channel).role == meshtastic_Channel_Role_PRIMARY) { - // backwards compatibility for Primary channels created before position_precision was set by default - precision = 13; - } else { - precision = 0; } meshtastic_MeshPacket *p = allocPositionPacket(); diff --git a/src/modules/RemoteHardwareModule.cpp b/src/modules/RemoteHardwareModule.cpp index 9bc8512b6..04cfeb651 100644 --- a/src/modules/RemoteHardwareModule.cpp +++ b/src/modules/RemoteHardwareModule.cpp @@ -83,9 +83,6 @@ bool RemoteHardwareModule::handleReceivedProtobuf(const meshtastic_MeshPacket &r switch (p.type) { case meshtastic_HardwareMessage_Type_WRITE_GPIOS: { - // Print notification to LCD screen - screen->print("Write GPIOs\n"); - pinModes(p.gpio_mask, OUTPUT, availablePins); for (uint8_t i = 0; i < NUM_GPIOS; i++) { uint64_t mask = 1ULL << i; @@ -98,10 +95,6 @@ bool RemoteHardwareModule::handleReceivedProtobuf(const meshtastic_MeshPacket &r } case meshtastic_HardwareMessage_Type_READ_GPIOS: { - // Print notification to LCD screen - if (screen) - screen->print("Read GPIOs\n"); - uint64_t res = digitalReads(p.gpio_mask, availablePins); // Send the reply diff --git a/src/modules/ReplyModule.cpp b/src/modules/ReplyModule.cpp index c4f63c6b1..8892aaa97 100644 --- a/src/modules/ReplyModule.cpp +++ b/src/modules/ReplyModule.cpp @@ -15,8 +15,6 @@ meshtastic_MeshPacket *ReplyModule::allocReply() LOG_INFO("Received message from=0x%0x, id=%d, msg=%.*s", req.from, req.id, p.payload.size, p.payload.bytes); #endif - screen->print("Send reply\n"); - const char *replyStr = "Message Received"; auto reply = allocDataPacket(); // Allocate a packet for sending reply->decoded.payload.size = strlen(replyStr); // You must specify how many bytes are in the reply diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index 8d280581c..f3921ef19 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -341,7 +341,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp serialPrint->write(p.payload.bytes, p.payload.size); } else if (moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG) { meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp)); - String sender = (node && node->has_user) ? node->user.short_name : "???"; + const char *sender = (node && node->has_user) ? node->user.short_name : "???"; serialPrint->println(); serialPrint->printf("%s: %s", sender, p.payload.bytes); serialPrint->println(); @@ -410,8 +410,8 @@ uint32_t SerialModule::getBaudRate() // Add this structure to help with parsing WindGust = 24.4 serial lines. struct ParsedLine { - String name; - String value; + char name[64]; + char value[128]; }; /** @@ -438,16 +438,30 @@ ParsedLine parseLine(const char *line) strncpy(nameBuf, line, nameLen); nameBuf[nameLen] = '\0'; - // Create trimmed name string - String name = String(nameBuf); - name.trim(); + // Trim whitespace from name + char *nameStart = nameBuf; + while (*nameStart && isspace(*nameStart)) + nameStart++; + char *nameEnd = nameStart + strlen(nameStart) - 1; + while (nameEnd > nameStart && isspace(*nameEnd)) + *nameEnd-- = '\0'; - // Extract value after equals sign - String value = String(equals + 1); - value.trim(); + // Copy trimmed name + strncpy(result.name, nameStart, sizeof(result.name) - 1); + result.name[sizeof(result.name) - 1] = '\0'; + + // Extract value part (after equals) + const char *valueStart = equals + 1; + while (*valueStart && isspace(*valueStart)) + valueStart++; + strncpy(result.value, valueStart, sizeof(result.value) - 1); + result.value[sizeof(result.value) - 1] = '\0'; + + // Trim trailing whitespace from value + char *valueEnd = result.value + strlen(result.value) - 1; + while (valueEnd > result.value && isspace(*valueEnd)) + *valueEnd-- = '\0'; - result.name = name; - result.value = value; return result; } @@ -517,16 +531,16 @@ void SerialModule::processWXSerial() memcpy(line, &serialBytes[lineStart], lineEnd - lineStart); ParsedLine parsed = parseLine(line); - if (parsed.name.length() > 0) { - if (parsed.name == "WindDir") { - strlcpy(windDir, parsed.value.c_str(), sizeof(windDir)); + if (strlen(parsed.name) > 0) { + if (strcmp(parsed.name, "WindDir") == 0) { + strlcpy(windDir, parsed.value, sizeof(windDir)); double radians = GeoCoord::toRadians(strtof(windDir, nullptr)); dir_sum_sin += sin(radians); dir_sum_cos += cos(radians); dirCount++; gotwind = true; - } else if (parsed.name == "WindSpeed") { - strlcpy(windVel, parsed.value.c_str(), sizeof(windVel)); + } else if (strcmp(parsed.name, "WindSpeed") == 0) { + strlcpy(windVel, parsed.value, sizeof(windVel)); float newv = strtof(windVel, nullptr); velSum += newv; velCount++; @@ -534,28 +548,28 @@ void SerialModule::processWXSerial() lull = newv; } gotwind = true; - } else if (parsed.name == "WindGust") { - strlcpy(windGust, parsed.value.c_str(), sizeof(windGust)); + } else if (strcmp(parsed.name, "WindGust") == 0) { + strlcpy(windGust, parsed.value, sizeof(windGust)); float newg = strtof(windGust, nullptr); if (newg > gust) { gust = newg; } gotwind = true; - } else if (parsed.name == "BatVoltage") { - strlcpy(batVoltage, parsed.value.c_str(), sizeof(batVoltage)); + } else if (strcmp(parsed.name, "BatVoltage") == 0) { + strlcpy(batVoltage, parsed.value, sizeof(batVoltage)); batVoltageF = strtof(batVoltage, nullptr); break; // last possible data we want so break - } else if (parsed.name == "CapVoltage") { - strlcpy(capVoltage, parsed.value.c_str(), sizeof(capVoltage)); + } else if (strcmp(parsed.name, "CapVoltage") == 0) { + strlcpy(capVoltage, parsed.value, sizeof(capVoltage)); capVoltageF = strtof(capVoltage, nullptr); - } else if (parsed.name == "GXTS04Temp" || parsed.name == "Temperature") { - strlcpy(temperature, parsed.value.c_str(), sizeof(temperature)); + } else if (strcmp(parsed.name, "GXTS04Temp") == 0 || strcmp(parsed.name, "Temperature") == 0) { + strlcpy(temperature, parsed.value, sizeof(temperature)); temperatureF = strtof(temperature, nullptr); - } else if (parsed.name == "RainIntSum") { - strlcpy(rainStr, parsed.value.c_str(), sizeof(rainStr)); + } else if (strcmp(parsed.name, "RainIntSum") == 0) { + strlcpy(rainStr, parsed.value, sizeof(rainStr)); rainSum = int(strtof(rainStr, nullptr)); - } else if (parsed.name == "Rain") { - strlcpy(rainStr, parsed.value.c_str(), sizeof(rainStr)); + } else if (strcmp(parsed.name, "Rain") == 0) { + strlcpy(rainStr, parsed.value, sizeof(rainStr)); rain = strtof(rainStr, nullptr); } } diff --git a/src/modules/SystemCommandsModule.cpp b/src/modules/SystemCommandsModule.cpp new file mode 100644 index 000000000..a6b01d68a --- /dev/null +++ b/src/modules/SystemCommandsModule.cpp @@ -0,0 +1,118 @@ +#include "SystemCommandsModule.h" +#include "meshUtils.h" +#if HAS_SCREEN +#include "graphics/Screen.h" +#include "graphics/SharedUIDisplay.h" +#endif +#include "GPS.h" +#include "MeshService.h" +#include "Module.h" +#include "NodeDB.h" +#include "main.h" +#include "modules/AdminModule.h" +#include "modules/ExternalNotificationModule.h" + +SystemCommandsModule *systemCommandsModule; + +SystemCommandsModule::SystemCommandsModule() +{ + if (inputBroker) + inputObserver.observe(inputBroker); +} + +int SystemCommandsModule::handleInputEvent(const InputEvent *event) +{ + LOG_INFO("Input event %u! kb %u", event->inputEvent, event->kbchar); + // System commands (all others fall through) + switch (event->kbchar) { + // Fn key symbols + case INPUT_BROKER_MSG_FN_SYMBOL_ON: + IF_SCREEN(screen->setFunctionSymbol("Fn")); + return 0; + case INPUT_BROKER_MSG_FN_SYMBOL_OFF: + IF_SCREEN(screen->removeFunctionSymbol("Fn")); + return 0; + // Brightness + case INPUT_BROKER_MSG_BRIGHTNESS_UP: + IF_SCREEN(screen->increaseBrightness()); + LOG_DEBUG("Increase Screen Brightness"); + return 0; + case INPUT_BROKER_MSG_BRIGHTNESS_DOWN: + IF_SCREEN(screen->decreaseBrightness()); + LOG_DEBUG("Decrease Screen Brightness"); + return 0; + // Mute + case INPUT_BROKER_MSG_MUTE_TOGGLE: + if (moduleConfig.external_notification.enabled && externalNotificationModule) { + bool isMuted = externalNotificationModule->getMute(); + externalNotificationModule->setMute(!isMuted); + IF_SCREEN(graphics::isMuted = !isMuted; if (!isMuted) externalNotificationModule->stopNow(); + screen->showOverlayBanner(isMuted ? "Notifications\nEnabled" : "Notifications\nDisabled", 3000);) + } + return 0; + // Bluetooth + case INPUT_BROKER_MSG_BLUETOOTH_TOGGLE: + config.bluetooth.enabled = !config.bluetooth.enabled; + LOG_INFO("User toggled Bluetooth"); + nodeDB->saveToDisk(); +#if defined(ARDUINO_ARCH_NRF52) + if (!config.bluetooth.enabled) { + disableBluetooth(); + IF_SCREEN(screen->showOverlayBanner("Bluetooth OFF\nRebooting", 3000)); + rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 2000; + } else { + IF_SCREEN(screen->showOverlayBanner("Bluetooth ON\nRebooting", 3000)); + rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + } +#else + if (!config.bluetooth.enabled) { + disableBluetooth(); + IF_SCREEN(screen->showOverlayBanner("Bluetooth OFF", 3000)); + } else { + IF_SCREEN(screen->showOverlayBanner("Bluetooth ON\nRebooting", 3000)); + rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + } +#endif + return 0; + case INPUT_BROKER_MSG_REBOOT: + IF_SCREEN(screen->showOverlayBanner("Rebooting...", 0)); + nodeDB->saveToDisk(); + rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + // runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + return true; + } + + switch (event->inputEvent) { + // GPS + case INPUT_BROKER_GPS_TOGGLE: + LOG_WARN("GPS Toggle"); +#if !MESHTASTIC_EXCLUDE_GPS + if (gps) { + LOG_WARN("GPS Toggle2"); + gps->toggleGpsMode(); + const char *msg = + (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) ? "GPS Enabled" : "GPS Disabled"; + IF_SCREEN(screen->forceDisplay(); screen->showOverlayBanner(msg, 3000);) + } +#endif + return true; + // Mesh ping + case INPUT_BROKER_SEND_PING: + service->refreshLocalMeshNode(); + if (service->trySendPosition(NODENUM_BROADCAST, true)) { + IF_SCREEN(screen->showOverlayBanner("Position\nUpdate Sent", 3000)); + } else { + IF_SCREEN(screen->showOverlayBanner("Node Info\nUpdate Sent", 3000)); + } + return true; + // Power control + case INPUT_BROKER_SHUTDOWN: + LOG_ERROR("Shutting down"); + IF_SCREEN(screen->showOverlayBanner("Shutting down...")); + nodeDB->saveToDisk(); + shutdownAtMsec = millis() + DEFAULT_SHUTDOWN_SECONDS * 1000; + // runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + return true; + } + return false; +} \ No newline at end of file diff --git a/src/modules/SystemCommandsModule.h b/src/modules/SystemCommandsModule.h new file mode 100644 index 000000000..44910f443 --- /dev/null +++ b/src/modules/SystemCommandsModule.h @@ -0,0 +1,19 @@ +#pragma once + +#include "MeshModule.h" +#include "configuration.h" +#include "input/InputBroker.h" +#include +#include + +class SystemCommandsModule +{ + CallbackObserver inputObserver = + CallbackObserver(this, &SystemCommandsModule::handleInputEvent); + + public: + SystemCommandsModule(); + int handleInputEvent(const InputEvent *event); +}; + +extern SystemCommandsModule *systemCommandsModule; \ No newline at end of file diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index fafb28699..2472b95b1 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -1,6 +1,6 @@ #include "configuration.h" -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include("Adafruit_PM25AQI.h") +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include("Adafruit_PM25AQI.h") #include "../mesh/generated/meshtastic/telemetry.pb.h" #include "AirQualityTelemetry.h" diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index aaab8d0e6..375d1e596 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -1,6 +1,6 @@ #include "configuration.h" -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR #include "../mesh/generated/meshtastic/telemetry.pb.h" #include "Default.h" @@ -11,12 +11,15 @@ #include "RTC.h" #include "Router.h" #include "UnitConversions.h" +#include "buzz.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" #include "main.h" +#include "modules/ExternalNotificationModule.h" #include "power.h" #include "sleep.h" #include "target_specific.h" #include -#include #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR_EXTERNAL @@ -25,6 +28,10 @@ #include "Sensor/RCWL9620Sensor.h" #include "Sensor/nullSensor.h" +namespace graphics +{ +extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr); +} #if __has_include() #include "Sensor/AHT10.h" AHT10Sensor aht10Sensor; @@ -344,120 +351,152 @@ bool EnvironmentTelemetryModule::wantUIFrame() void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { - display->setTextAlignment(TEXT_ALIGN_LEFT); + // === Setup display === + display->clear(); display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + int line = 1; - if (lastMeasurementPacket == nullptr) { - // If there's no valid packet, display "Environment" - display->drawString(x, y, "Environment"); - display->drawString(x, y += _fontHeight(FONT_SMALL), "No measurement"); + // === Set Title + const char *titleStr = (SCREEN_WIDTH > 128) ? "Environment" : "Env."; + + // === Header === + graphics::drawCommonHeader(display, x, y, titleStr); + + // === Row spacing setup === + const int rowHeight = FONT_HEIGHT_SMALL - 4; + int currentY = graphics::getTextPositions(display)[line++]; + + // === Show "No Telemetry" if no data available === + if (!lastMeasurementPacket) { + display->drawString(x, currentY, "No Telemetry"); return; } - // Decode the last measurement packet - meshtastic_Telemetry lastMeasurement; - uint32_t agoSecs = service->GetTimeSinceMeshPacket(lastMeasurementPacket); - const char *lastSender = getSenderShortName(*lastMeasurementPacket); - + // Decode the telemetry message from the latest received packet const meshtastic_Data &p = lastMeasurementPacket->decoded; - if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &lastMeasurement)) { - display->drawString(x, y, "Measurement Error"); - LOG_ERROR("Unable to decode last packet"); + meshtastic_Telemetry telemetry; + if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &telemetry)) { + display->drawString(x, currentY, "No Telemetry"); return; } - // Display "Env. From: ..." on its own - display->drawString(x, y, "Env. From: " + String(lastSender) + " (" + String(agoSecs) + "s)"); + const auto &m = telemetry.variant.environment_metrics; - // Prepare sensor data strings - String sensorData[10]; - int sensorCount = 0; + // Check if any telemetry field has valid data + bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || m.iaq != 0 || m.voltage != 0 || + m.current != 0 || m.lux != 0 || m.white_lux != 0 || m.weight != 0 || m.distance != 0 || m.radiation != 0; - if (lastMeasurement.variant.environment_metrics.has_temperature || - lastMeasurement.variant.environment_metrics.has_relative_humidity) { - String last_temp = String(lastMeasurement.variant.environment_metrics.temperature, 0) + "°C"; - if (moduleConfig.telemetry.environment_display_fahrenheit) { - last_temp = - String(UnitConversions::CelsiusToFahrenheit(lastMeasurement.variant.environment_metrics.temperature), 0) + "°F"; + if (!hasAny) { + display->drawString(x, currentY, "No Telemetry"); + return; + } + + // === First line: Show sender name + time since received (left), and first metric (right) === + const char *sender = getSenderShortName(*lastMeasurementPacket); + uint32_t agoSecs = service->GetTimeSinceMeshPacket(lastMeasurementPacket); + String agoStr = (agoSecs > 864000) ? "?" + : (agoSecs > 3600) ? String(agoSecs / 3600) + "h" + : (agoSecs > 60) ? String(agoSecs / 60) + "m" + : String(agoSecs) + "s"; + + String leftStr = String(sender) + " (" + agoStr + ")"; + display->drawString(x, currentY, leftStr); // Left side: who and when + + // === Collect sensor readings as label strings (no icons) === + std::vector entries; + + if (m.has_temperature) { + String tempStr = moduleConfig.telemetry.environment_display_fahrenheit + ? "Tmp: " + String(UnitConversions::CelsiusToFahrenheit(m.temperature), 1) + "°F" + : "Tmp: " + String(m.temperature, 1) + "°C"; + entries.push_back(tempStr); + } + if (m.has_relative_humidity) + entries.push_back("Hum: " + String(m.relative_humidity, 0) + "%"); + if (m.barometric_pressure != 0) + entries.push_back("Prss: " + String(m.barometric_pressure, 0) + " hPa"); + if (m.iaq != 0) { + String aqi = "IAQ: " + String(m.iaq); + const char *bannerMsg = nullptr; // Default: no banner + + if (m.iaq <= 25) + aqi += " (Excellent)"; + else if (m.iaq <= 50) + aqi += " (Good)"; + else if (m.iaq <= 100) + aqi += " (Moderate)"; + else if (m.iaq <= 150) + aqi += " (Poor)"; + else if (m.iaq <= 200) { + aqi += " (Unhealthy)"; + bannerMsg = "Unhealthy IAQ"; + } else if (m.iaq <= 300) { + aqi += " (Very Unhealthy)"; + bannerMsg = "Very Unhealthy IAQ"; + } else { + aqi += " (Hazardous)"; + bannerMsg = "Hazardous IAQ"; } - sensorData[sensorCount++] = - "Temp/Hum: " + last_temp + " / " + String(lastMeasurement.variant.environment_metrics.relative_humidity, 0) + "%"; - } + entries.push_back(aqi); - if (lastMeasurement.variant.environment_metrics.barometric_pressure != 0) { - sensorData[sensorCount++] = - "Press: " + String(lastMeasurement.variant.environment_metrics.barometric_pressure, 0) + "hPA"; - } + // === IAQ alert logic === + static uint32_t lastAlertTime = 0; + uint32_t now = millis(); - if (lastMeasurement.variant.environment_metrics.voltage != 0) { - sensorData[sensorCount++] = "Volt/Cur: " + String(lastMeasurement.variant.environment_metrics.voltage, 0) + "V / " + - String(lastMeasurement.variant.environment_metrics.current, 0) + "mA"; - } + bool isOwnTelemetry = lastMeasurementPacket->from == nodeDB->getNodeNum(); + bool isCooldownOver = (now - lastAlertTime > 60000); - if (lastMeasurement.variant.environment_metrics.iaq != 0) { - sensorData[sensorCount++] = "IAQ: " + String(lastMeasurement.variant.environment_metrics.iaq); - } + if (isOwnTelemetry && bannerMsg && isCooldownOver) { + LOG_INFO("drawFrame: IAQ %d (own) — showing banner: %s", m.iaq, bannerMsg); + screen->showOverlayBanner(bannerMsg, 3000); - if (lastMeasurement.variant.environment_metrics.distance != 0) { - sensorData[sensorCount++] = "Water Level: " + String(lastMeasurement.variant.environment_metrics.distance, 0) + "mm"; - } - - if (lastMeasurement.variant.environment_metrics.weight != 0) { - sensorData[sensorCount++] = "Weight: " + String(lastMeasurement.variant.environment_metrics.weight, 0) + "kg"; - } - - if (lastMeasurement.variant.environment_metrics.radiation != 0) { - sensorData[sensorCount++] = "Rad: " + String(lastMeasurement.variant.environment_metrics.radiation, 2) + "µR/h"; - } - - if (lastMeasurement.variant.environment_metrics.lux != 0) { - sensorData[sensorCount++] = "Illuminance: " + String(lastMeasurement.variant.environment_metrics.lux, 2) + "lx"; - } - - if (lastMeasurement.variant.environment_metrics.white_lux != 0) { - sensorData[sensorCount++] = "W_Lux: " + String(lastMeasurement.variant.environment_metrics.white_lux, 2) + "lx"; - } - - static int scrollOffset = 0; - static bool scrollingDown = true; - static uint32_t lastScrollTime = millis(); - - // Determine how many lines we can fit on display - // Calculated once only: display dimensions don't change during runtime. - static int maxLines = 0; - if (!maxLines) { - const int16_t paddingTop = _fontHeight(FONT_SMALL); // Heading text - const int16_t paddingBottom = 8; // Indicator dots - maxLines = (display->getHeight() - paddingTop - paddingBottom) / _fontHeight(FONT_SMALL); - assert(maxLines > 0); - } - - // Draw as many lines of data as we can fit - int linesToShow = min(maxLines, sensorCount); - for (int i = 0; i < linesToShow; i++) { - int index = (scrollOffset + i) % sensorCount; - display->drawString(x, y += _fontHeight(FONT_SMALL), sensorData[index]); - } - - // Only scroll if there are more than 3 sensor data lines - if (sensorCount > 3) { - // Update scroll offset every 5 seconds - if (millis() - lastScrollTime > 5000) { - if (scrollingDown) { - scrollOffset++; - if (scrollOffset + linesToShow >= sensorCount) { - scrollingDown = false; - } - } else { - scrollOffset--; - if (scrollOffset <= 0) { - scrollingDown = true; - } + // Only buzz if IAQ is over 200 + if (m.iaq > 200 && moduleConfig.external_notification.enabled && !externalNotificationModule->getMute()) { + playLongBeep(); } - lastScrollTime = millis(); + + lastAlertTime = now; } } + if (m.voltage != 0 || m.current != 0) + entries.push_back(String(m.voltage, 1) + "V / " + String(m.current, 0) + "mA"); + if (m.lux != 0) + entries.push_back("Light: " + String(m.lux, 0) + "lx"); + if (m.white_lux != 0) + entries.push_back("White: " + String(m.white_lux, 0) + "lx"); + if (m.weight != 0) + entries.push_back("Weight: " + String(m.weight, 0) + "kg"); + if (m.distance != 0) + entries.push_back("Level: " + String(m.distance, 0) + "mm"); + if (m.radiation != 0) + entries.push_back("Rad: " + String(m.radiation, 2) + " µR/h"); + + // === Show first available metric on top-right of first line === + if (!entries.empty()) { + String valueStr = entries.front(); + int rightX = SCREEN_WIDTH - display->getStringWidth(valueStr); + display->drawString(rightX, currentY, valueStr); + entries.erase(entries.begin()); // Remove from queue + } + + // === Advance to next line for remaining telemetry entries === + currentY += rowHeight; + + // === Draw remaining entries in 2-column format (left and right) === + for (size_t i = 0; i < entries.size(); i += 2) { + // Left column + display->drawString(x, currentY, entries[i]); + + // Right column if it exists + if (i + 1 < entries.size()) { + int rightX = SCREEN_WIDTH / 2; + display->drawString(rightX, currentY, entries[i + 1]); + } + + currentY += rowHeight; + } } bool EnvironmentTelemetryModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_Telemetry *t) diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index a2a18ba03..3a735b1fa 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -118,22 +118,31 @@ void HealthTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * } // Display "Health From: ..." on its own - display->drawString(x, y, "Health From: " + String(lastSender) + "(" + String(agoSecs) + "s)"); + char headerStr[64]; + snprintf(headerStr, sizeof(headerStr), "Health From: %s(%ds)", lastSender, (int)agoSecs); + display->drawString(x, y, headerStr); - String last_temp = String(lastMeasurement.variant.health_metrics.temperature, 0) + "°C"; + char last_temp[16]; if (moduleConfig.telemetry.environment_display_fahrenheit) { - last_temp = String(UnitConversions::CelsiusToFahrenheit(lastMeasurement.variant.health_metrics.temperature), 0) + "°F"; + snprintf(last_temp, sizeof(last_temp), "%.0f°F", + UnitConversions::CelsiusToFahrenheit(lastMeasurement.variant.health_metrics.temperature)); + } else { + snprintf(last_temp, sizeof(last_temp), "%.0f°C", lastMeasurement.variant.health_metrics.temperature); } // Continue with the remaining details - display->drawString(x, y += _fontHeight(FONT_SMALL), "Temp: " + last_temp); + char tempStr[32]; + snprintf(tempStr, sizeof(tempStr), "Temp: %s", last_temp); + display->drawString(x, y += _fontHeight(FONT_SMALL), tempStr); if (lastMeasurement.variant.health_metrics.has_heart_bpm) { - display->drawString(x, y += _fontHeight(FONT_SMALL), - "Heart Rate: " + String(lastMeasurement.variant.health_metrics.heart_bpm, 0) + " bpm"); + char heartStr[32]; + snprintf(heartStr, sizeof(heartStr), "Heart Rate: %.0f bpm", lastMeasurement.variant.health_metrics.heart_bpm); + display->drawString(x, y += _fontHeight(FONT_SMALL), heartStr); } if (lastMeasurement.variant.health_metrics.has_spO2) { - display->drawString(x, y += _fontHeight(FONT_SMALL), - "spO2: " + String(lastMeasurement.variant.health_metrics.spO2, 0) + " %"); + char spo2Str[32]; + snprintf(spo2Str, sizeof(spo2Str), "spO2: %.0f %%", lastMeasurement.variant.health_metrics.spO2); + display->drawString(x, y += _fontHeight(FONT_SMALL), spo2Str); } } diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 54ec90dae..df1505226 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -10,6 +10,7 @@ #include "PowerTelemetry.h" #include "RTC.h" #include "Router.h" +#include "graphics/SharedUIDisplay.h" #include "main.h" #include "power.h" #include "sleep.h" @@ -21,6 +22,11 @@ #include "graphics/ScreenFonts.h" #include +namespace graphics +{ +extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr); +} + int32_t PowerTelemetryModule::runOnce() { if (sleepOnNextExecution == true) { @@ -103,13 +109,20 @@ bool PowerTelemetryModule::wantUIFrame() void PowerTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + display->clear(); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); + int line = 1; + + // === Set Title + const char *titleStr = (SCREEN_WIDTH > 128) ? "Power Telem." : "Power"; + + // === Header === + graphics::drawCommonHeader(display, x, y, titleStr); if (lastMeasurementPacket == nullptr) { - // In case of no valid packet, display "Power Telemetry", "No measurement" - display->drawString(x, y, "Power Telemetry"); - display->drawString(x, y += _fontHeight(FONT_SMALL), "No measurement"); + // In case of no valid packet, display "Power Telemetry", "No measurement" + display->drawString(x, graphics::getTextPositions(display)[line++], "No measurement"); return; } @@ -120,29 +133,35 @@ void PowerTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *s const meshtastic_Data &p = lastMeasurementPacket->decoded; if (!pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Telemetry_msg, &lastMeasurement)) { - display->drawString(x, y, "Measurement Error"); + display->drawString(x, graphics::getTextPositions(display)[line++], "Measurement Error"); LOG_ERROR("Unable to decode last packet"); return; } // Display "Pow. From: ..." - display->drawString(x, y, "Pow. From: " + String(lastSender) + "(" + String(agoSecs) + "s)"); + char fromStr[64]; + snprintf(fromStr, sizeof(fromStr), "Pow. From: %s (%us)", lastSender, agoSecs); + display->drawString(x, graphics::getTextPositions(display)[line++], fromStr); // Display current and voltage based on ...power_metrics.has_[channel/voltage/current]... flags - if (lastMeasurement.variant.power_metrics.has_ch1_voltage || lastMeasurement.variant.power_metrics.has_ch1_current) { - display->drawString(x, y += _fontHeight(FONT_SMALL), - "Ch1: " + String(lastMeasurement.variant.power_metrics.ch1_voltage, 2) + "V " + - String(lastMeasurement.variant.power_metrics.ch1_current, 0) + "mA"); + const auto &m = lastMeasurement.variant.power_metrics; + int lineY = textSecondLine; + + auto drawLine = [&](const char *label, float voltage, float current) { + char lineStr[64]; + snprintf(lineStr, sizeof(lineStr), "%s: %.2fV %.0fmA", label, voltage, current); + display->drawString(x, lineY, lineStr); + lineY += _fontHeight(FONT_SMALL); + }; + + if (m.has_ch1_voltage || m.has_ch1_current) { + drawLine("Ch1", m.ch1_voltage, m.ch1_current); } - if (lastMeasurement.variant.power_metrics.has_ch2_voltage || lastMeasurement.variant.power_metrics.has_ch2_current) { - display->drawString(x, y += _fontHeight(FONT_SMALL), - "Ch2: " + String(lastMeasurement.variant.power_metrics.ch2_voltage, 2) + "V " + - String(lastMeasurement.variant.power_metrics.ch2_current, 0) + "mA"); + if (m.has_ch2_voltage || m.has_ch2_current) { + drawLine("Ch2", m.ch2_voltage, m.ch2_current); } - if (lastMeasurement.variant.power_metrics.has_ch3_voltage || lastMeasurement.variant.power_metrics.has_ch3_current) { - display->drawString(x, y += _fontHeight(FONT_SMALL), - "Ch3: " + String(lastMeasurement.variant.power_metrics.ch3_voltage, 2) + "V " + - String(lastMeasurement.variant.power_metrics.ch3_current, 0) + "mA"); + if (m.has_ch3_voltage || m.has_ch3_current) { + drawLine("Ch3", m.ch3_voltage, m.ch3_current); } } diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.cpp b/src/modules/Telemetry/Sensor/BME680Sensor.cpp index 0e0212bc5..fce029deb 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.cpp +++ b/src/modules/Telemetry/Sensor/BME680Sensor.cpp @@ -137,17 +137,17 @@ void BME680Sensor::updateState() #endif } -void BME680Sensor::checkStatus(String functionName) +void BME680Sensor::checkStatus(const char *functionName) { if (bme680.status < BSEC_OK) - LOG_ERROR("%s BSEC2 code: %s", functionName.c_str(), String(bme680.status).c_str()); + LOG_ERROR("%s BSEC2 code: %d", functionName, bme680.status); else if (bme680.status > BSEC_OK) - LOG_WARN("%s BSEC2 code: %s", functionName.c_str(), String(bme680.status).c_str()); + LOG_WARN("%s BSEC2 code: %d", functionName, bme680.status); if (bme680.sensor.status < BME68X_OK) - LOG_ERROR("%s BME68X code: %s", functionName.c_str(), String(bme680.sensor.status).c_str()); + LOG_ERROR("%s BME68X code: %d", functionName, bme680.sensor.status); else if (bme680.sensor.status > BME68X_OK) - LOG_WARN("%s BME68X code: %s", functionName.c_str(), String(bme680.sensor.status).c_str()); + LOG_WARN("%s BME68X code: %d", functionName, bme680.sensor.status); } #endif diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.h b/src/modules/Telemetry/Sensor/BME680Sensor.h index 249c4b3e7..ce1fa4f3b 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.h +++ b/src/modules/Telemetry/Sensor/BME680Sensor.h @@ -34,7 +34,7 @@ class BME680Sensor : public TelemetrySensor BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY}; void loadState(); void updateState(); - void checkStatus(String functionName); + void checkStatus(const char *functionName); public: BME680Sensor(); diff --git a/src/modules/WaypointModule.cpp b/src/modules/WaypointModule.cpp index 479a973c2..578e7183a 100644 --- a/src/modules/WaypointModule.cpp +++ b/src/modules/WaypointModule.cpp @@ -2,9 +2,13 @@ #include "NodeDB.h" #include "PowerFSM.h" #include "configuration.h" +#include "graphics/draw/CompassRenderer.h" + #if HAS_SCREEN #include "gps/RTC.h" #include "graphics/Screen.h" +#include "graphics/TimeFormatters.h" +#include "graphics/draw/NodeListRenderer.h" #include "main.h" #endif @@ -48,6 +52,8 @@ ProcessMessage WaypointModule::handleReceived(const meshtastic_MeshPacket &mp) bool WaypointModule::shouldDraw() { #if !MESHTASTIC_EXCLUDE_WAYPOINT + if (screen == nullptr) + return false; // If no waypoint to show if (!devicestate.has_rx_waypoint) return false; @@ -79,13 +85,15 @@ bool WaypointModule::shouldDraw() /// Draw the last waypoint we received void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + if (screen == nullptr) + return; // Prepare to draw display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); // Handle inverted display // Unsure of expected behavior: for now, copy drawNodeInfo - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); // Decode the waypoint @@ -101,7 +109,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, // Get timestamp info. Will pass as a field to drawColumns static char lastStr[20]; - screen->getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr)); + getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr)); // Will contain distance information, passed as a field to drawColumns static char distStr[20]; @@ -115,7 +123,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, // Dimensions / co-ordinates for the compass/circle int16_t compassX = 0, compassY = 0; - uint16_t compassDiam = graphics::Screen::getCompassDiam(display->getWidth(), display->getHeight()); + uint16_t compassDiam = graphics::CompassRenderer::getCompassDiam(display->getWidth(), display->getHeight()); if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { compassX = x + display->getWidth() - compassDiam / 2 - 5; @@ -133,7 +141,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, myHeading = (screen->getHeading()) * PI / 180; // gotta convert compass degrees to Radians else myHeading = screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i)); - screen->drawCompassNorth(display, compassX, compassY, myHeading); + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, (compassDiam / 2)); // Compass bearing to waypoint float bearingToOther = @@ -142,7 +150,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, // If the top of the compass is not a static north we need adjust bearingToOther based on heading if (!config.display.compass_north_top) bearingToOther -= myHeading; - screen->drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); + graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); float bearingToOtherDegrees = (bearingToOther < 0) ? bearingToOther + 2 * PI : bearingToOther; bearingToOtherDegrees = bearingToOtherDegrees * 180 / PI; @@ -180,11 +188,11 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, // Undo color-inversion, if set prior to drawing header // Unsure of expected behavior? For now: copy drawNodeInfo - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { display->setColor(BLACK); } // Must be after distStr is populated - screen->drawColumns(display, x, y, fields); + graphics::NodeListRenderer::drawColumns(display, x, y, fields); } #endif diff --git a/src/modules/esp32/PaxcounterModule.cpp b/src/modules/esp32/PaxcounterModule.cpp index b27586771..8b1fc5302 100644 --- a/src/modules/esp32/PaxcounterModule.cpp +++ b/src/modules/esp32/PaxcounterModule.cpp @@ -3,6 +3,9 @@ #include "Default.h" #include "MeshService.h" #include "PaxcounterModule.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" #include PaxcounterModule *paxcounterModule; @@ -112,20 +115,32 @@ int32_t PaxcounterModule::runOnce() #if HAS_SCREEN #include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" void PaxcounterModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + int line = 1; + + // === Set Title + const char *titleStr = "Pax"; + + // === Header === + graphics::drawCommonHeader(display, x, y, titleStr); + char buffer[50]; display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawString(x + 0, y + 0, "PAX"); libpax_counter_count(&count_from_libpax); display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_SMALL); - display->drawStringf(display->getWidth() / 2 + x, 0 + y + 12, buffer, "WiFi: %d\nBLE: %d\nuptime: %ds", - count_from_libpax.wifi_count, count_from_libpax.ble_count, millis() / 1000); + display->drawStringf(display->getWidth() / 2 + x, graphics::getTextPositions(display)[line++], buffer, + "WiFi: %d\nBLE: %d\nUptime: %ds", count_from_libpax.wifi_count, count_from_libpax.ble_count, + millis() / 1000); } #endif // HAS_SCREEN diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 02e5b0bd4..f08ee00f9 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -10,6 +10,7 @@ #ifdef HAS_BMA423 #include "BMA423Sensor.h" #endif +#include "BMM150Sensor.h" #include "BMX160Sensor.h" #include "ICM20948Sensor.h" #include "LIS3DHSensor.h" @@ -107,6 +108,9 @@ class AccelerometerThread : public concurrency::OSThread case ScanI2C::DeviceType::ICM20948: sensor = new ICM20948Sensor(device); break; + case ScanI2C::DeviceType::BMM150: + sensor = new BMM150Sensor(device); + break; #ifdef HAS_QMA6100P case ScanI2C::DeviceType::QMA6100P: sensor = new QMA6100PSensor(device); diff --git a/src/motion/BMM150Sensor.cpp b/src/motion/BMM150Sensor.cpp new file mode 100644 index 000000000..4b3a1215c --- /dev/null +++ b/src/motion/BMM150Sensor.cpp @@ -0,0 +1,93 @@ +#include "BMM150Sensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) + +// screen is defined in main.cpp +extern graphics::Screen *screen; +#endif + +// Flag when an interrupt has been detected +volatile static bool BMM150_IRQ = false; + +BMM150Sensor::BMM150Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} + +bool BMM150Sensor::init() +{ + // Initialise the sensor + sensor = BMM150Singleton::GetInstance(device); + return sensor->init(device); +} + +int32_t BMM150Sensor::runOnce() +{ +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN + float heading = sensor->getCompassDegree(); + + switch (config.display.compass_orientation) { + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0_INVERTED: + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_0: + break; + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90: + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_90_INVERTED: + heading += 90; + break; + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180: + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_180_INVERTED: + heading += 180; + break; + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270: + case meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED: + heading += 270; + break; + } + if (screen) + screen->setHeading(heading); +#endif + return MOTION_SENSOR_CHECK_INTERVAL_MS; +} + +// ---------------------------------------------------------------------- +// BMM150Singleton +// ---------------------------------------------------------------------- + +// Get a singleton wrapper for an Sparkfun BMM_150_I2C +BMM150Singleton *BMM150Singleton::GetInstance(ScanI2C::FoundDevice device) +{ +#if defined(WIRE_INTERFACES_COUNT) && (WIRE_INTERFACES_COUNT > 1) + TwoWire &bus = (device.address.port == ScanI2C::I2CPort::WIRE1 ? Wire1 : Wire); +#else + TwoWire &bus = Wire; // fallback if only one I2C interface +#endif + if (pinstance == nullptr) { + pinstance = new BMM150Singleton(&bus, device.address.address); + } + return pinstance; +} + +BMM150Singleton::~BMM150Singleton() {} + +BMM150Singleton *BMM150Singleton::pinstance{nullptr}; + +// Initialise the BMM150 Sensor +// https://github.com/DFRobot/DFRobot_BMM150/blob/master/examples/getGeomagneticData/getGeomagneticData.ino +bool BMM150Singleton::init(ScanI2C::FoundDevice device) +{ + + // startup + LOG_DEBUG("BMM150 begin on addr 0x%02X (port=%d)", device.address.address, device.address.port); + uint8_t status = begin(); + if (status != 0) { + LOG_DEBUG("BMM150 init error %u", status); + return false; + } + + // SW reset to make sure the device starts in a known state + setOperationMode(BMM150_POWERMODE_NORMAL); + setPresetMode(BMM150_PRESETMODE_LOWPOWER); + setRate(BMM150_DATA_RATE_02HZ); + setMeasurementXYZ(); + return true; +} + +#endif \ No newline at end of file diff --git a/src/motion/BMM150Sensor.h b/src/motion/BMM150Sensor.h new file mode 100644 index 000000000..879045400 --- /dev/null +++ b/src/motion/BMM150Sensor.h @@ -0,0 +1,57 @@ +#pragma once +#ifndef _BMM_150_SENSOR_H_ +#define _BMM_150_SENSOR_H_ + +#include "MotionSensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() + +#include "Fusion/Fusion.h" +#include + +// The I2C address of the Accelerometer (if found) from main.cpp +extern ScanI2C::DeviceAddress accelerometer_found; + +// Singleton wrapper +class BMM150Singleton : public DFRobot_BMM150_I2C +{ + private: + static BMM150Singleton *pinstance; + + protected: + BMM150Singleton(TwoWire *tw, uint8_t addr) : DFRobot_BMM150_I2C(tw, addr) {} + ~BMM150Singleton(); + + public: + // Create a singleton instance (not thread safe) + static BMM150Singleton *GetInstance(ScanI2C::FoundDevice device); + + // Singletons should not be cloneable. + BMM150Singleton(BMM150Singleton &other) = delete; + + // Singletons should not be assignable. + void operator=(const BMM150Singleton &) = delete; + + // Initialise the motion sensor singleton for normal operation + bool init(ScanI2C::FoundDevice device); +}; + +class BMM150Sensor : public MotionSensor +{ + private: + BMM150Singleton *sensor = nullptr; + bool showingScreen = false; + + public: + explicit BMM150Sensor(ScanI2C::FoundDevice foundDevice); + + // Initialise the motion sensor + virtual bool init() override; + + // Called each time our sensor gets a chance to run + virtual int32_t runOnce() override; +}; + +#endif + +#endif \ No newline at end of file diff --git a/src/motion/BMX160Sensor.cpp b/src/motion/BMX160Sensor.cpp index a3909ea3a..003ee850c 100755 --- a/src/motion/BMX160Sensor.cpp +++ b/src/motion/BMX160Sensor.cpp @@ -37,7 +37,8 @@ int32_t BMX160Sensor::runOnce() if (!showingScreen) { powerFSM.trigger(EVENT_PRESS); // keep screen alive during calibration showingScreen = true; - screen->startAlert((FrameCallback)drawFrameCalibration); + if (screen) + screen->startAlert((FrameCallback)drawFrameCalibration); } if (magAccel.x > highestX) @@ -58,7 +59,8 @@ int32_t BMX160Sensor::runOnce() doCalibration = false; endCalibrationAt = 0; showingScreen = false; - screen->endAlert(); + if (screen) + screen->endAlert(); } // LOG_DEBUG("BMX160 min_x: %.4f, max_X: %.4f, min_Y: %.4f, max_Y: %.4f, min_Z: %.4f, max_Z: %.4f", lowestX, highestX, @@ -103,8 +105,8 @@ int32_t BMX160Sensor::runOnce() heading += 270; break; } - - screen->setHeading(heading); + if (screen) + screen->setHeading(heading); #endif return MOTION_SENSOR_CHECK_INTERVAL_MS; @@ -118,7 +120,8 @@ void BMX160Sensor::calibrate(uint16_t forSeconds) doCalibration = true; uint16_t calibrateFor = forSeconds * 1000; // calibrate for seconds provided endCalibrationAt = millis() + calibrateFor; - screen->setEndCalibration(endCalibrationAt); + if (screen) + screen->setEndCalibration(endCalibrationAt); #endif } diff --git a/src/motion/ICM20948Sensor.cpp b/src/motion/ICM20948Sensor.cpp index ecc48d39b..76ba8e8cf 100755 --- a/src/motion/ICM20948Sensor.cpp +++ b/src/motion/ICM20948Sensor.cpp @@ -60,7 +60,8 @@ int32_t ICM20948Sensor::runOnce() if (!showingScreen) { powerFSM.trigger(EVENT_PRESS); // keep screen alive during calibration showingScreen = true; - screen->startAlert((FrameCallback)drawFrameCalibration); + if (screen) + screen->startAlert((FrameCallback)drawFrameCalibration); } if (magX > highestX) @@ -81,7 +82,8 @@ int32_t ICM20948Sensor::runOnce() doCalibration = false; endCalibrationAt = 0; showingScreen = false; - screen->endAlert(); + if (screen) + screen->endAlert(); } // LOG_DEBUG("ICM20948 min_x: %.4f, max_X: %.4f, min_Y: %.4f, max_Y: %.4f, min_Z: %.4f, max_Z: %.4f", lowestX, highestX, @@ -124,8 +126,8 @@ int32_t ICM20948Sensor::runOnce() heading += 270; break; } - - screen->setHeading(heading); + if (screen) + screen->setHeading(heading); #endif // Wake on motion using polling - this is not as efficient as using hardware interrupt pin (see above) @@ -159,7 +161,8 @@ void ICM20948Sensor::calibrate(uint16_t forSeconds) doCalibration = true; uint16_t calibrateFor = forSeconds * 1000; // calibrate for seconds provided endCalibrationAt = millis() + calibrateFor; - screen->setEndCalibration(endCalibrationAt); + if (screen) + screen->setEndCalibration(endCalibrationAt); #endif } // ---------------------------------------------------------------------- diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp index 56738d355..b00460aff 100755 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -1,4 +1,5 @@ #include "MotionSensor.h" +#include "graphics/draw/CompassRenderer.h" #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C @@ -34,6 +35,8 @@ ScanI2C::I2CPort MotionSensor::devicePort() #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + if (screen == nullptr) + return; // int x_offset = display->width() / 2; // int y_offset = display->height() <= 80 ? 0 : 32; display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -46,7 +49,7 @@ void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState display->drawString(x, y + 40, timeRemainingBuffer); int16_t compassX = 0, compassY = 0; - uint16_t compassDiam = graphics::Screen::getCompassDiam(display->getWidth(), display->getHeight()); + uint16_t compassDiam = graphics::CompassRenderer::getCompassDiam(display->getWidth(), display->getHeight()); // coordinates for the center of the compass/circle if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { @@ -57,7 +60,7 @@ void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState compassY = y + FONT_HEIGHT_SMALL + (display->getHeight() - FONT_HEIGHT_SMALL) / 2; } display->drawCircle(compassX, compassY, compassDiam / 2); - screen->drawCompassNorth(display, compassX, compassY, screen->getHeading() * PI / 180); + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, screen->getHeading() * PI / 180, (compassDiam / 2)); } #endif diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 894579a2f..137c92056 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -355,7 +355,7 @@ void MQTT::onReceive(char *topic, byte *payload, size_t length) // if another "/" was added, parse string up to that character channelName = strtok(channelName, "/") ? strtok(channelName, "/") : channelName; // We allow downlink JSON packets only on a channel named "mqtt" - meshtastic_Channel &sendChannel = channels.getByName(channelName); + const meshtastic_Channel &sendChannel = channels.getByName(channelName); if (!(strncasecmp(channels.getGlobalId(sendChannel.index), Channels::mqttChannel, strlen(Channels::mqttChannel)) == 0 && sendChannel.settings.downlink_enabled)) { LOG_WARN("JSON downlink received on channel not called 'mqtt' or without downlink enabled"); @@ -491,7 +491,7 @@ void MQTT::reconnect() return; // Don't try to connect directly to the server } #if HAS_NETWORKING - const PubSubConfig config(moduleConfig.mqtt); + const PubSubConfig ps_config(moduleConfig.mqtt); MQTTClient *clientConnection = mqttClient.get(); #if MQTT_SUPPORTS_TLS if (moduleConfig.mqtt.tls_enabled) { @@ -502,7 +502,7 @@ void MQTT::reconnect() LOG_INFO("Use non-TLS-encrypted session"); } #endif - if (connectPubSub(config, pubSub, *clientConnection)) { + if (connectPubSub(ps_config, pubSub, *clientConnection)) { enabled = true; // Start running background process again runASAP = true; reconnectCount = 0; diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 177a07eb4..3ab06695b 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -94,31 +94,33 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks bluetoothStatus->updateStatus(new meshtastic::BluetoothStatus(std::to_string(passkey))); #if HAS_SCREEN // Todo: migrate this display code back into Screen class, and observe bluetoothStatus - screen->startAlert([passkey](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { - char btPIN[16] = "888888"; - snprintf(btPIN, sizeof(btPIN), "%06u", passkey); - int x_offset = display->width() / 2; - int y_offset = display->height() <= 80 ? 0 : 32; - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_MEDIUM); - display->drawString(x_offset + x, y_offset + y, "Bluetooth"); + if (screen) { + screen->startAlert([passkey](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { + char btPIN[16] = "888888"; + snprintf(btPIN, sizeof(btPIN), "%06u", passkey); + int x_offset = display->width() / 2; + int y_offset = display->height() <= 80 ? 0 : 12; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_MEDIUM); + display->drawString(x_offset + x, y_offset + y, "Bluetooth"); - display->setFont(FONT_SMALL); - y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_MEDIUM - 4 : y_offset + FONT_HEIGHT_MEDIUM + 5; - display->drawString(x_offset + x, y_offset + y, "Enter this code"); + display->setFont(FONT_SMALL); + y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_MEDIUM - 4 : y_offset + FONT_HEIGHT_MEDIUM + 5; + display->drawString(x_offset + x, y_offset + y, "Enter this code"); - display->setFont(FONT_LARGE); - String displayPin(btPIN); - String pin = displayPin.substring(0, 3) + " " + displayPin.substring(3, 6); - y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_SMALL - 5 : y_offset + FONT_HEIGHT_SMALL + 5; - display->drawString(x_offset + x, y_offset + y, pin); + display->setFont(FONT_LARGE); + char pin[8]; + snprintf(pin, sizeof(pin), "%.3s %.3s", btPIN, btPIN + 3); + y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_SMALL - 5 : y_offset + FONT_HEIGHT_SMALL + 5; + display->drawString(x_offset + x, y_offset + y, pin); - display->setFont(FONT_SMALL); - String deviceName = "Name: "; - deviceName.concat(getDeviceName()); - y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_LARGE - 6 : y_offset + FONT_HEIGHT_LARGE + 5; - display->drawString(x_offset + x, y_offset + y, deviceName); - }); + display->setFont(FONT_SMALL); + char deviceName[64]; + snprintf(deviceName, sizeof(deviceName), "Name: %s", getDeviceName()); + y_offset = display->height() == 64 ? y_offset + FONT_HEIGHT_LARGE - 6 : y_offset + FONT_HEIGHT_LARGE + 5; + display->drawString(x_offset + x, y_offset + y, deviceName); + }); + } #endif passkeyShowing = true; @@ -134,7 +136,8 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks // Todo: migrate this display code back into Screen class, and observe bluetoothStatus if (passkeyShowing) { passkeyShowing = false; - screen->endAlert(); + if (screen) + screen->endAlert(); } } diff --git a/src/platform/esp32/WiFiOTA.cpp b/src/platform/esp32/WiFiOTA.cpp index eac124dda..4cf157b4c 100644 --- a/src/platform/esp32/WiFiOTA.cpp +++ b/src/platform/esp32/WiFiOTA.cpp @@ -80,13 +80,13 @@ bool trySwitchToOTA() return true; } -String getVersion() +const char *getVersion() { const esp_partition_t *part = getAppPartition(); - esp_app_desc_t app_desc; + static esp_app_desc_t app_desc; if (!getAppDesc(part, &app_desc)) - return String(); - return String(app_desc.version); + return ""; + return app_desc.version; } } // namespace WiFiOTA diff --git a/src/platform/esp32/WiFiOTA.h b/src/platform/esp32/WiFiOTA.h index 61860ed5e..5a7ee348a 100644 --- a/src/platform/esp32/WiFiOTA.h +++ b/src/platform/esp32/WiFiOTA.h @@ -12,7 +12,7 @@ bool isUpdated(); void recoverConfig(meshtastic_Config_NetworkConfig *network); void saveConfig(meshtastic_Config_NetworkConfig *network); bool trySwitchToOTA(); -String getVersion(); +const char *getVersion(); } // namespace WiFiOTA #endif // WIFIOTA_H diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 4f6fe7c6b..89e92afc6 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -331,7 +331,7 @@ bool NRF52Bluetooth::onPairingPasskey(uint16_t conn_handle, uint8_t const passke char btPIN[16] = "888888"; snprintf(btPIN, sizeof(btPIN), "%06u", configuredPasskey); int x_offset = display->width() / 2; - int y_offset = display->height() <= 80 ? 0 : 32; + int y_offset = display->height() <= 80 ? 0 : 12; display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_MEDIUM); display->drawString(x_offset + x, y_offset + y, "Bluetooth"); diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index a69816d0b..684d20e84 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -121,10 +121,6 @@ #define BUTTON_PIN PIN_BUTTON1 #endif -#ifdef PIN_BUTTON2 -#define BUTTON_PIN_ALT PIN_BUTTON2 -#endif - #ifdef PIN_BUTTON_TOUCH #define BUTTON_PIN_TOUCH PIN_BUTTON_TOUCH #endif diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index cc0c417d3..f582a116d 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -143,10 +143,26 @@ void portduinoSetup() { printf("Set up Meshtastic on Portduino...\n"); int max_GPIO = 0; - const configNames GPIO_lines[] = { - cs_pin, irq_pin, busy_pin, reset_pin, sx126x_ant_sw_pin, txen_pin, - rxen_pin, displayDC, displayCS, displayBacklight, displayBacklightPWMChannel, displayReset, - touchscreenCS, touchscreenIRQ, user}; + const configNames GPIO_lines[] = {cs_pin, + irq_pin, + busy_pin, + reset_pin, + sx126x_ant_sw_pin, + txen_pin, + rxen_pin, + displayDC, + displayCS, + displayBacklight, + displayBacklightPWMChannel, + displayReset, + touchscreenCS, + touchscreenIRQ, + userButtonPin, + tbUpPin, + tbDownPin, + tbLeftPin, + tbRightPin, + tbPressPin}; std::string gpioChipName = "gpiochip"; settingsStrings[i2cdev] = ""; @@ -159,6 +175,11 @@ void portduinoSetup() settingsMap[ascii_logs] = !isatty(1); settingsMap[displayPanel] = no_screen; settingsMap[touchscreenModule] = no_touchscreen; + settingsMap[tbUpPin] = RADIOLIB_NC; + settingsMap[tbDownPin] = RADIOLIB_NC; + settingsMap[tbLeftPin] = RADIOLIB_NC; + settingsMap[tbRightPin] = RADIOLIB_NC; + settingsMap[tbPressPin] = RADIOLIB_NC; YAML::Node yamlConfig; @@ -313,9 +334,34 @@ void portduinoSetup() // Need to bind all the configured GPIO pins so they're not simulated // TODO: If one of these fails, we should log and terminate - if (settingsMap.count(user) > 0 && settingsMap[user] != RADIOLIB_NC) { - if (initGPIOPin(settingsMap[user], defaultGpioChipName, settingsMap[user]) != ERRNO_OK) { - settingsMap[user] = RADIOLIB_NC; + if (settingsMap.count(userButtonPin) > 0 && settingsMap[userButtonPin] != RADIOLIB_NC) { + if (initGPIOPin(settingsMap[userButtonPin], defaultGpioChipName, settingsMap[userButtonPin]) != ERRNO_OK) { + settingsMap[userButtonPin] = RADIOLIB_NC; + } + } + if (settingsMap.count(tbUpPin) > 0 && settingsMap[tbUpPin] != RADIOLIB_NC) { + if (initGPIOPin(settingsMap[tbUpPin], defaultGpioChipName, settingsMap[tbUpPin]) != ERRNO_OK) { + settingsMap[tbUpPin] = RADIOLIB_NC; + } + } + if (settingsMap.count(tbDownPin) > 0 && settingsMap[tbDownPin] != RADIOLIB_NC) { + if (initGPIOPin(settingsMap[tbDownPin], defaultGpioChipName, settingsMap[tbDownPin]) != ERRNO_OK) { + settingsMap[tbDownPin] = RADIOLIB_NC; + } + } + if (settingsMap.count(tbLeftPin) > 0 && settingsMap[tbLeftPin] != RADIOLIB_NC) { + if (initGPIOPin(settingsMap[tbLeftPin], defaultGpioChipName, settingsMap[tbLeftPin]) != ERRNO_OK) { + settingsMap[tbLeftPin] = RADIOLIB_NC; + } + } + if (settingsMap.count(tbRightPin) > 0 && settingsMap[tbRightPin] != RADIOLIB_NC) { + if (initGPIOPin(settingsMap[tbRightPin], defaultGpioChipName, settingsMap[tbRightPin]) != ERRNO_OK) { + settingsMap[tbRightPin] = RADIOLIB_NC; + } + } + if (settingsMap.count(tbPressPin) > 0 && settingsMap[tbPressPin] != RADIOLIB_NC) { + if (initGPIOPin(settingsMap[tbPressPin], defaultGpioChipName, settingsMap[tbPressPin]) != ERRNO_OK) { + settingsMap[tbPressPin] = RADIOLIB_NC; } } if (settingsMap[displayPanel] != no_screen) { @@ -377,6 +423,8 @@ int initGPIOPin(int pinNum, const std::string gpioChipName, int line) { #ifdef PORTDUINO_LINUX_HARDWARE std::string gpio_name = "GPIO" + std::to_string(pinNum); + std::cout << gpio_name; + printf("\n"); try { GPIOPin *csPin; csPin = new LinuxGPIOPin(pinNum, gpioChipName.c_str(), line, gpio_name.c_str()); @@ -498,7 +546,7 @@ bool loadConfig(const char *configPath) } } if (yamlConfig["GPIO"]) { - settingsMap[user] = yamlConfig["GPIO"]["User"].as(RADIOLIB_NC); + settingsMap[userButtonPin] = yamlConfig["GPIO"]["User"].as(RADIOLIB_NC); } if (yamlConfig["GPS"]) { std::string serialPath = yamlConfig["GPS"]["SerialPath"].as(""); @@ -588,6 +636,12 @@ bool loadConfig(const char *configPath) if (yamlConfig["Input"]) { settingsStrings[keyboardDevice] = (yamlConfig["Input"]["KeyboardDevice"]).as(""); settingsStrings[pointerDevice] = (yamlConfig["Input"]["PointerDevice"]).as(""); + settingsMap[userButtonPin] = yamlConfig["Input"]["User"].as(RADIOLIB_NC); + settingsMap[tbUpPin] = yamlConfig["Input"]["TrackballUp"].as(RADIOLIB_NC); + settingsMap[tbDownPin] = yamlConfig["Input"]["TrackballDown"].as(RADIOLIB_NC); + settingsMap[tbLeftPin] = yamlConfig["Input"]["TrackballLeft"].as(RADIOLIB_NC); + settingsMap[tbRightPin] = yamlConfig["Input"]["TrackballRight"].as(RADIOLIB_NC); + settingsMap[tbPressPin] = yamlConfig["Input"]["TrackballPress"].as(RADIOLIB_NC); } if (yamlConfig["Webserver"]) { diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index d324aaf47..43aea4218 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -57,7 +57,12 @@ enum configNames { lora_usb_serial_num, lora_usb_pid, lora_usb_vid, - user, + userButtonPin, + tbUpPin, + tbDownPin, + tbLeftPin, + tbRightPin, + tbPressPin, spidev, spiSpeed, i2cdev, diff --git a/src/platform/portduino/architecture.h b/src/platform/portduino/architecture.h index a5e263d5a..07d0aeee0 100644 --- a/src/platform/portduino/architecture.h +++ b/src/platform/portduino/architecture.h @@ -8,6 +8,9 @@ #define HW_VENDOR meshtastic_HardwareModel_PORTDUINO +#ifndef HAS_BUTTON +#define HAS_BUTTON 1 +#endif #ifndef HAS_WIFI #define HAS_WIFI 1 #endif @@ -22,4 +25,12 @@ #endif #ifndef HAS_SENSOR #define HAS_SENSOR 1 +#endif +#ifndef HAS_TRACKBALL +#define HAS_TRACKBALL 1 +#define TB_DOWN (uint8_t) settingsMap[tbDownPin] +#define TB_UP (uint8_t) settingsMap[tbUpPin] +#define TB_LEFT (uint8_t) settingsMap[tbLeftPin] +#define TB_RIGHT (uint8_t) settingsMap[tbRightPin] +#define TB_PRESS (uint8_t) settingsMap[tbPressPin] #endif \ No newline at end of file diff --git a/src/power.h b/src/power.h index d7fa7f8a9..33a356d92 100644 --- a/src/power.h +++ b/src/power.h @@ -78,8 +78,8 @@ extern NullSensor ina3221Sensor; #endif #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !defined(ARCH_STM32WL) -#include "modules/Telemetry/Sensor/MAX17048Sensor.h" #if __has_include() +#include "modules/Telemetry/Sensor/MAX17048Sensor.h" extern MAX17048Sensor max17048Sensor; #else extern NullSensor max17048Sensor; diff --git a/src/shutdown.h b/src/shutdown.h index f02cb7964..998944677 100644 --- a/src/shutdown.h +++ b/src/shutdown.h @@ -41,8 +41,8 @@ void powerCommandsCheck() } #if defined(ARCH_ESP32) || defined(ARCH_NRF52) - if (shutdownAtMsec) { - screen->startAlert("Shutting down..."); + if (shutdownAtMsec && screen) { + screen->showOverlayBanner("Shutting Down...", 0); // stays on screen } #endif diff --git a/src/sleep.cpp b/src/sleep.cpp index 6d1b2f348..09484f46e 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -221,8 +221,8 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN #endif powerMon->setState(meshtastic_PowerMon_State_CPU_DeepSleep); - - screen->doDeepSleep(); // datasheet says this will draw only 10ua + if (screen) + screen->doDeepSleep(); // datasheet says this will draw only 10ua if (!skipSaveNodeDb) { nodeDB->saveToDisk(); diff --git a/suppressions.txt b/suppressions.txt index 04937523d..ab57c9298 100644 --- a/suppressions.txt +++ b/suppressions.txt @@ -53,4 +53,8 @@ internalAstError:*/CrossPlatformCryptoEngine.cpp uninitMemberVar:*/AudioThread.h // False positive constVariableReference:*/Channels.cpp -constParameterPointer:*/unishox2.c \ No newline at end of file +constParameterPointer:*/unishox2.c + +useStlAlgorithm + +variableScope \ No newline at end of file diff --git a/variants/ELECROW-ThinkNode-M1/nicheGraphics.h b/variants/ELECROW-ThinkNode-M1/nicheGraphics.h index c2c351925..f3b709261 100644 --- a/variants/ELECROW-ThinkNode-M1/nicheGraphics.h +++ b/variants/ELECROW-ThinkNode-M1/nicheGraphics.h @@ -22,6 +22,9 @@ #include "graphics/niche/Drivers/EInk/GDEY0154D67.h" #include "graphics/niche/Inputs/TwoButton.h" +// Button feedback +#include "buzz.h" + void setupNicheGraphics() { using namespace NicheGraphics; @@ -98,8 +101,14 @@ void setupNicheGraphics() buttons->setWiring(1, PIN_BUTTON1); buttons->setTiming(1, 50, 500); // 500ms before latch buttons->setHandlerDown(1, [backlight]() { backlight->peek(); }); - buttons->setHandlerLongPress(1, [backlight]() { backlight->latch(); }); - buttons->setHandlerShortPress(1, [backlight]() { backlight->off(); }); + buttons->setHandlerLongPress(1, [backlight]() { + backlight->latch(); + playBeep(); + }); + buttons->setHandlerShortPress(1, [backlight]() { + backlight->off(); + playBoop(); + }); // Begin handling button events buttons->start(); diff --git a/variants/ELECROW-ThinkNode-M1/variant.h b/variants/ELECROW-ThinkNode-M1/variant.h index 2e91e378d..79e31c54a 100644 --- a/variants/ELECROW-ThinkNode-M1/variant.h +++ b/variants/ELECROW-ThinkNode-M1/variant.h @@ -63,6 +63,9 @@ extern "C" { * Buttons */ #define PIN_BUTTON2 (32 + 10) +#define ALT_BUTTON_PIN PIN_BUTTON2 +#define ALT_BUTTON_ACTIVE_LOW true +#define ALT_BUTTON_ACTIVE_PULLUP true #define PIN_BUTTON1 (32 + 7) // #define PIN_BUTTON1 (0 + 11) diff --git a/variants/ELECROW-ThinkNode-M2/variant.h b/variants/ELECROW-ThinkNode-M2/variant.h index a6bb40f1a..cd8d43555 100644 --- a/variants/ELECROW-ThinkNode-M2/variant.h +++ b/variants/ELECROW-ThinkNode-M2/variant.h @@ -3,6 +3,9 @@ #define PIN_BUTTON1 47 // 功能键 #define PIN_BUTTON2 4 // 电源键 +#define ALT_BUTTON_PIN PIN_BUTTON2 +#define ALT_BUTTON_ACTIVE_LOW false +#define ALT_BUTTON_ACTIVE_PULLUP false #define LED_POWER 6 #define ADC_V 42 @@ -60,4 +63,3 @@ #define HAS_GPS 0 #define BUTTON_PIN PIN_BUTTON1 -#define BUTTON_PIN_ALT PIN_BUTTON2 diff --git a/variants/heltec_capsule_sensor_v3/variant.h b/variants/heltec_capsule_sensor_v3/variant.h index 415de0559..b30b7fc3e 100644 --- a/variants/heltec_capsule_sensor_v3/variant.h +++ b/variants/heltec_capsule_sensor_v3/variant.h @@ -3,6 +3,8 @@ #define EXT_PWR_DETECT 35 #define BUTTON_PIN 18 +#define BUTTON_ACTIVE_LOW false +#define BUTTON_ACTIVE_PULLUP false #define BATTERY_PIN 7 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage #define ADC_CHANNEL ADC1_GPIO7_CHANNEL diff --git a/variants/heltec_mesh_node_t114/variant.h b/variants/heltec_mesh_node_t114/variant.h index 426085a26..798c3538a 100644 --- a/variants/heltec_mesh_node_t114/variant.h +++ b/variants/heltec_mesh_node_t114/variant.h @@ -56,6 +56,7 @@ extern "C" { #define TFT_WIDTH 240 #define TFT_OFFSET_X 0 #define TFT_OFFSET_Y 0 + // #define TFT_OFFSET_ROTATION 0 // #define SCREEN_ROTATE // #define SCREEN_TRANSITION_FRAMERATE 5 diff --git a/variants/heltec_sensor_hub/variant.h b/variants/heltec_sensor_hub/variant.h index 771cefee3..8c5d31c9a 100644 --- a/variants/heltec_sensor_hub/variant.h +++ b/variants/heltec_sensor_hub/variant.h @@ -1,6 +1,8 @@ #define EXT_PWR_DETECT 20 #define BUTTON_PIN 17 +#define BUTTON_ACTIVE_LOW false +#define BUTTON_ACTIVE_PULLUP false #define BATTERY_PIN 7 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage #define ADC_CHANNEL ADC1_GPIO7_CHANNEL diff --git a/variants/heltec_vision_master_e213/nicheGraphics.h b/variants/heltec_vision_master_e213/nicheGraphics.h index 7eccb2955..26f393f6c 100644 --- a/variants/heltec_vision_master_e213/nicheGraphics.h +++ b/variants/heltec_vision_master_e213/nicheGraphics.h @@ -21,6 +21,9 @@ #include "graphics/niche/Drivers/EInk/LCMEN2R13EFC1.h" #include "graphics/niche/Inputs/TwoButton.h" +// Button feedback +#include "buzz.h" + void setupNicheGraphics() { using namespace NicheGraphics; @@ -84,8 +87,11 @@ void setupNicheGraphics() buttons->setHandlerLongPress(0, [inkhud]() { inkhud->longpress(); }); // #1: Aux Button - buttons->setWiring(1, BUTTON_PIN_SECONDARY); - buttons->setHandlerShortPress(1, [inkhud]() { inkhud->nextTile(); }); + buttons->setWiring(1, PIN_BUTTON2); + buttons->setHandlerShortPress(1, [inkhud]() { + inkhud->nextTile(); + playBoop(); + }); // Begin handling button events buttons->start(); diff --git a/variants/heltec_vision_master_e213/variant.h b/variants/heltec_vision_master_e213/variant.h index ebb2c341f..60f4e00cc 100644 --- a/variants/heltec_vision_master_e213/variant.h +++ b/variants/heltec_vision_master_e213/variant.h @@ -1,7 +1,7 @@ #define LED_PIN 45 // LED is not populated on earliest board variant #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 +#define PIN_BUTTON2 21 // Second built-in button +#define ALT_BUTTON_PIN PIN_BUTTON2 // Send the up event // I2C #define I2C_SDA SDA diff --git a/variants/heltec_vision_master_e290/nicheGraphics.h b/variants/heltec_vision_master_e290/nicheGraphics.h index af78df746..f3cf6355e 100644 --- a/variants/heltec_vision_master_e290/nicheGraphics.h +++ b/variants/heltec_vision_master_e290/nicheGraphics.h @@ -34,6 +34,9 @@ Different NicheGraphics UIs and different hardware variants will each have their #include "graphics/niche/Drivers/EInk/DEPG0290BNS800.h" #include "graphics/niche/Inputs/TwoButton.h" +// Button feedback +#include "buzz.h" + void setupNicheGraphics() { using namespace NicheGraphics; @@ -97,8 +100,11 @@ void setupNicheGraphics() buttons->setHandlerLongPress(0, [inkhud]() { inkhud->longpress(); }); // #1: Aux Button - buttons->setWiring(1, BUTTON_PIN_SECONDARY); - buttons->setHandlerShortPress(1, [inkhud]() { inkhud->nextTile(); }); + buttons->setWiring(1, PIN_BUTTON2); + buttons->setHandlerShortPress(1, [inkhud]() { + inkhud->nextTile(); + playBoop(); + }); // Begin handling button events buttons->start(); diff --git a/variants/heltec_vision_master_e290/variant.h b/variants/heltec_vision_master_e290/variant.h index 02986d26b..d7bae7dc2 100644 --- a/variants/heltec_vision_master_e290/variant.h +++ b/variants/heltec_vision_master_e290/variant.h @@ -1,7 +1,7 @@ #define LED_PIN 45 // LED is not populated on earliest board variant #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 +#define PIN_BUTTON2 21 // Second built-in button +#define ALT_BUTTON_PIN PIN_BUTTON2 // Send the up event // I2C #define I2C_SDA SDA diff --git a/variants/heltec_vision_master_t190/variant.h b/variants/heltec_vision_master_t190/variant.h index 788466919..a6a809207 100644 --- a/variants/heltec_vision_master_t190/variant.h +++ b/variants/heltec_vision_master_t190/variant.h @@ -1,6 +1,7 @@ +#ifndef HAS_TFT #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 +#define PIN_BUTTON2 21 // Second built-in button +#define ALT_BUTTON_PIN PIN_BUTTON2 // Send the up event // I2C #define I2C_SDA SDA @@ -68,4 +69,5 @@ #define SX126X_RESET LORA_RESET #define SX126X_DIO2_AS_RF_SWITCH -#define SX126X_DIO3_TCXO_VOLTAGE 1.8 \ No newline at end of file +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +#endif // HAS_TFT \ No newline at end of file diff --git a/variants/link32_s3_v1/variant.h b/variants/link32_s3_v1/variant.h index 1f8a7435a..a16c0ff68 100644 --- a/variants/link32_s3_v1/variant.h +++ b/variants/link32_s3_v1/variant.h @@ -6,7 +6,9 @@ #define USE_SSD1306 #define BUTTON_PIN 0 // Button pin for this board -#define BUTTON_PIN_ALT 36 +#define CANCEL_BUTTON_PIN 36 +#define CANCEL_BUTTON_ACTIVE_LOW true +#define CANCEL_BUTTON_ACTIVE_PULLUP true #define HAS_NEOPIXEL // If defined, we will use the neopixel library #define NEOPIXEL_DATA 35 // Neopixel pin for this board diff --git a/variants/nano-g1-explorer/variant.h b/variants/nano-g1-explorer/variant.h index 3d5d71acc..f3640241a 100644 --- a/variants/nano-g1-explorer/variant.h +++ b/variants/nano-g1-explorer/variant.h @@ -3,9 +3,7 @@ #define I2C_SDA 21 #define I2C_SCL 22 -#define BUTTON_PIN 36 // The user button (information button) GPIO on the Nano G1 explorer -// #define BUTTON_PIN_ALT 13 // Alternate GPIO for an external button if needed. Does anyone use this? It is not documented -// anywhere. +#define BUTTON_PIN 36 // The user button (information button) GPIO on the Nano G1 explorer #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module. // common pinout for their SX1262 vs RF95 modules - both can be enabled and we will probe at runtime for RF95 and if diff --git a/variants/nano-g1/variant.h b/variants/nano-g1/variant.h index dd8355492..2521c3ffe 100644 --- a/variants/nano-g1/variant.h +++ b/variants/nano-g1/variant.h @@ -3,9 +3,7 @@ #define I2C_SDA 21 #define I2C_SCL 22 -#define BUTTON_PIN 36 // The middle button GPIO on the Nano G1 -// #define BUTTON_PIN_ALT 13 // Alternate GPIO for an external button if needed. Does anyone use this? It is not documented -// anywhere. +#define BUTTON_PIN 36 // The middle button GPIO on the Nano G1 #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module. // common pinout for their SX1262 vs RF95 modules - both can be enabled and we will probe at runtime for RF95 and if diff --git a/variants/picomputer-s3/platformio.ini b/variants/picomputer-s3/platformio.ini index df2d0dfdc..b861b5496 100644 --- a/variants/picomputer-s3/platformio.ini +++ b/variants/picomputer-s3/platformio.ini @@ -26,21 +26,15 @@ extends = env:picomputer-s3 build_flags = ${env:picomputer-s3.build_flags} - -D MESHTASTIC_EXCLUDE_CANNEDMESSAGES=1 - -D MESHTASTIC_EXCLUDE_INPUTBROKER=1 - -D MESHTASTIC_EXCLUDE_BLUETOOTH=1 - -D MESHTASTIC_EXCLUDE_WEBSERVER=1 - -D MESHTASTIC_EXCLUDE_SERIAL=1 - -D MESHTASTIC_EXCLUDE_SOCKETAPI=1 -D INPUTDRIVER_MATRIX_TYPE=1 -D USE_PIN_BUZZER=PIN_BUZZER -D USE_SX127x - -D HAS_SCREEN=0 + -D HAS_SCREEN=1 -D HAS_TFT=1 - -D RAM_SIZE=1024 - -D LV_LVGL_H_INCLUDE_SIMPLE - -D LV_CONF_INCLUDE_SIMPLE - -D LV_COMP_CONF_INCLUDE_SIMPLE + -D RAM_SIZE=1560 + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE -D LV_USE_SYSMON=0 -D LV_USE_PROFILER=0 -D LV_USE_PERF_MONITOR=0 @@ -51,7 +45,7 @@ build_flags = -D LGFX_DRIVER=LGFX_PICOMPUTER_S3 -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_PICOMPUTER_S3.h\" -D VIEW_320x240 -; -D USE_DOUBLE_BUFFER +; -D USE_DOUBLE_BUFFER -D USE_PACKET_API lib_deps = diff --git a/variants/portduino/platformio.ini b/variants/portduino/platformio.ini index fe89ad6e6..6da827508 100644 --- a/variants/portduino/platformio.ini +++ b/variants/portduino/platformio.ini @@ -26,7 +26,8 @@ build_flags = ${native_base.build_flags} -Os -lX11 -linput -lxkbcommon -ffunctio -D RAM_SIZE=16384 -D USE_X11=1 -D HAS_TFT=1 - -D HAS_SCREEN=0 + -D HAS_SCREEN=1 + -D LV_CACHE_DEF_SIZE=6291456 -D LV_BUILD_TEST=0 -D LV_USE_LIBINPUT=1 @@ -41,7 +42,6 @@ build_flags = ${native_base.build_flags} -Os -lX11 -linput -lxkbcommon -ffunctio !pkg-config --libs openssl --silence-errors || : build_src_filter = ${native_base.build_src_filter} - - [env:native-fb] extends = native_base @@ -56,7 +56,7 @@ build_flags = ${native_base.build_flags} -Os -ffunction-sections -fdata-sections -D USE_FRAMEBUFFER=1 -D LV_COLOR_DEPTH=32 -D HAS_TFT=1 - -D HAS_SCREEN=0 + -D HAS_SCREEN=1 -D LV_BUILD_TEST=0 -D LV_USE_LOG=0 -D LV_USE_EVDEV=1 @@ -72,7 +72,6 @@ build_flags = ${native_base.build_flags} -Os -ffunction-sections -fdata-sections !pkg-config --libs openssl --silence-errors || : build_src_filter = ${native_base.build_src_filter} - - [env:native-tft-debug] extends = native_base diff --git a/variants/seeed-sensecap-indicator/platformio.ini b/variants/seeed-sensecap-indicator/platformio.ini index b643288a6..2187ebd8a 100644 --- a/variants/seeed-sensecap-indicator/platformio.ini +++ b/variants/seeed-sensecap-indicator/platformio.ini @@ -36,17 +36,9 @@ upload_speed = 460800 build_flags = ${env:seeed-sensecap-indicator.build_flags} - -D MESHTASTIC_EXCLUDE_CANNEDMESSAGES=1 - -D MESHTASTIC_EXCLUDE_INPUTBROKER=1 - -D MESHTASTIC_EXCLUDE_SCREEN=1 - -D MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR=1 - -D MESHTASTIC_EXCLUDE_WEBSERVER=1 - -D MESHTASTIC_EXCLUDE_SERIAL=1 - -D MESHTASTIC_EXCLUDE_SOCKETAPI=1 -D INPUTDRIVER_BUTTON_TYPE=38 - -D HAS_TELEMETRY=0 -D CONFIG_DISABLE_HAL_LOCKS=1 - -D HAS_SCREEN=0 + -D HAS_SCREEN=1 -D HAS_TFT=1 -D DISPLAY_SET_RESOLUTION -D RAM_SIZE=4096 diff --git a/variants/seeed-sensecap-indicator/variant.h b/variants/seeed-sensecap-indicator/variant.h index 1010e04c8..8915395f3 100644 --- a/variants/seeed-sensecap-indicator/variant.h +++ b/variants/seeed-sensecap-indicator/variant.h @@ -7,9 +7,10 @@ #define SENSOR_PORT_NUM 2 #define SENSOR_BAUD_RATE 115200 -#if !HAS_TFT #define BUTTON_PIN 38 -#endif +#define BUTTON_ACTIVE_LOW true +#define BUTTON_ACTIVE_PULLUP true + // #define BUTTON_NEED_PULLUP // #define BATTERY_PIN 27 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage diff --git a/variants/seeed_wio_tracker_L1/variant.h b/variants/seeed_wio_tracker_L1/variant.h index 38f2b71ff..daa6afb8e 100644 --- a/variants/seeed_wio_tracker_L1/variant.h +++ b/variants/seeed_wio_tracker_L1/variant.h @@ -33,15 +33,15 @@ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Button Configuration // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -#define BUTTON_PIN D13 // This is the Program Button +#define CANCEL_BUTTON_PIN D13 // This is the Program Button // #define BUTTON_NEED_PULLUP 1 -#define BUTTON_ACTIVE_LOW true -#define BUTTON_ACTIVE_PULLUP false +#define CANCEL_BUTTON_ACTIVE_LOW true +#define CANCEL_BUTTON_ACTIVE_PULLUP false -#define BUTTON_PIN_TOUCH 13 // Touch button -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -// Digital Pin Mapping (D0-D10) -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// #define BUTTON_PIN_TOUCH 13 // Touch button +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Digital Pin Mapping (D0-D10) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #define D0 0 // P1.06 GNSS_WAKEUP/IO0 #define D1 1 // P0.07 LORA_DIO1 #define D2 2 // P1.07 LORA_RESET diff --git a/variants/station-g1/variant.h b/variants/station-g1/variant.h index 9a3c37b73..6c3a39261 100644 --- a/variants/station-g1/variant.h +++ b/variants/station-g1/variant.h @@ -6,9 +6,7 @@ #define I2C_SDA1 14 // Second i2c channel on external IO connector #define I2C_SCL1 15 // Second i2c channel on external IO connector -#define BUTTON_PIN 36 // The middle button GPIO on the Nano G1 -// #define BUTTON_PIN_ALT 13 // Alternate GPIO for an external button if needed. Does anyone use this? It is not documented -// anywhere. +#define BUTTON_PIN 36 // The middle button GPIO on the Nano G1 #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module. // common pinout for their SX1262 vs RF95 modules - both can be enabled and we will probe at runtime for RF95 and if diff --git a/variants/t-deck/platformio.ini b/variants/t-deck/platformio.ini index 0e644001e..c00ab5e04 100644 --- a/variants/t-deck/platformio.ini +++ b/variants/t-deck/platformio.ini @@ -25,11 +25,6 @@ extends = env:t-deck build_flags = ${env:t-deck.build_flags} -D CONFIG_DISABLE_HAL_LOCKS=1 ; "feels" to be a bit more stable without locks - -D MESHTASTIC_EXCLUDE_CANNEDMESSAGES=1 - -D MESHTASTIC_EXCLUDE_INPUTBROKER=1 - -D MESHTASTIC_EXCLUDE_WEBSERVER=1 - -D MESHTASTIC_EXCLUDE_SERIAL=1 - -D MESHTASTIC_EXCLUDE_SOCKETAPI=1 -D INPUTDRIVER_I2C_KBD_TYPE=0x55 -D INPUTDRIVER_ENCODER_TYPE=3 -D INPUTDRIVER_ENCODER_LEFT=1 @@ -39,7 +34,7 @@ build_flags = -D INPUTDRIVER_ENCODER_BTN=0 -D INPUTDRIVER_BUTTON_TYPE=0 -D HAS_SDCARD - -D HAS_SCREEN=0 + -D HAS_SCREEN=1 -D HAS_TFT=1 -D USE_I2S_BUZZER -D RAM_SIZE=5120 diff --git a/variants/t-deck/variant.h b/variants/t-deck/variant.h index a21c786b3..9fa0018ec 100644 --- a/variants/t-deck/variant.h +++ b/variants/t-deck/variant.h @@ -1,7 +1,5 @@ #define TFT_CS 12 -#ifndef HAS_TFT // for TFT-UI the definitions are in device-ui -#define BUTTON_PIN 0 // ST7789 TFT LCD #define ST7789_CS TFT_CS @@ -24,7 +22,6 @@ #define SCREEN_ROTATE #define SCREEN_TRANSITION_FRAMERATE 5 #define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness -#endif #define HAS_TOUCHSCREEN 1 #define SCREEN_TOUCH_INT 16 @@ -34,10 +31,10 @@ #define USE_POWERSAVE #define SLEEP_TIME 120 -#ifndef HAS_TFT -#define BUTTON_PIN 0 -// #define BUTTON_NEED_PULLUP -#endif +#define TB_PRESS 0 +#define BUTTON_ACTIVE_LOW true +#define BUTTON_ACTIVE_PULLUP true + #define GPS_DEFAULT_NOT_PRESENT 1 #define GPS_RX_PIN 44 #define GPS_TX_PIN 43 diff --git a/variants/t-echo/variant.h b/variants/t-echo/variant.h index 3f96ffc83..4f3a53ebf 100644 --- a/variants/t-echo/variant.h +++ b/variants/t-echo/variant.h @@ -61,8 +61,12 @@ extern "C" { * Buttons */ #define PIN_BUTTON1 (32 + 10) +#define BUTTON_ACTIVE_LOW true +#define BUTTON_ACTIVE_PULLUP true #define PIN_BUTTON2 (0 + 18) // 0.18 is labeled on the board as RESET but we configure it in the bootloader as a regular GPIO #define PIN_BUTTON_TOUCH (0 + 11) // 0.11 is the soft touch button on T-Echo +#define BUTTON_TOUCH_ACTIVE_LOW true +#define BUTTON_TOUCH_ACTIVE_PULLUP true #define BUTTON_CLICK_MS 400 #define BUTTON_TOUCH_MS 200 diff --git a/variants/tbeam-s3-core/variant.h b/variants/tbeam-s3-core/variant.h index cc706459f..dabd52980 100644 --- a/variants/tbeam-s3-core/variant.h +++ b/variants/tbeam-s3-core/variant.h @@ -7,8 +7,6 @@ #define I2C_SCL 18 // For QMC6310 sensors and screens #define BUTTON_PIN 0 // The middle button GPIO on the T-Beam S3 -// #define BUTTON_PIN_ALT 13 // Alternate GPIO for an external button if needed. Does anyone use this? It is not documented -// anywhere. // #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module. #define LED_STATE_ON 0 // State when LED is lit diff --git a/variants/tbeam/variant.h b/variants/tbeam/variant.h index 8771c20d2..5b521a2de 100644 --- a/variants/tbeam/variant.h +++ b/variants/tbeam/variant.h @@ -4,8 +4,8 @@ #define I2C_SCL 22 #define BUTTON_PIN 38 // The middle button GPIO on the T-Beam -// #define BUTTON_PIN_ALT 13 // Alternate GPIO for an external button if needed. Does anyone use this? It is not documented -// anywhere. +#define BUTTON_ACTIVE_LOW true +#define BUTTON_ACTIVE_PULLUP true #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module. #define LED_STATE_ON 0 // State when LED is lit diff --git a/variants/unphone/platformio.ini b/variants/unphone/platformio.ini index 399d65b03..ef0f62b60 100644 --- a/variants/unphone/platformio.ini +++ b/variants/unphone/platformio.ini @@ -36,21 +36,16 @@ extends = env:unphone build_flags = ${env:unphone.build_flags} -D CONFIG_DISABLE_HAL_LOCKS=1 - -D MESHTASTIC_EXCLUDE_CANNEDMESSAGES=1 - -D MESHTASTIC_EXCLUDE_INPUTBROKER=1 - -D MESHTASTIC_EXCLUDE_WEBSERVER=1 - -D MESHTASTIC_EXCLUDE_SERIAL=1 - -D MESHTASTIC_EXCLUDE_SOCKETAPI=1 -D INPUTDRIVER_BUTTON_TYPE=21 - -D HAS_SCREEN=0 + -D HAS_SCREEN=1 -D HAS_TFT=1 -D HAS_SDCARD -D DISPLAY_SET_RESOLUTION -D RAM_SIZE=6144 -D LV_CACHE_DEF_SIZE=2097152 - -D LV_LVGL_H_INCLUDE_SIMPLE - -D LV_CONF_INCLUDE_SIMPLE - -D LV_COMP_CONF_INCLUDE_SIMPLE + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE -D LV_BUILD_TEST=0 -D LV_USE_SYSMON=0 -D LV_USE_PROFILER=0 diff --git a/variants/unphone/variant.h b/variants/unphone/variant.h index eaf142721..aef650278 100644 --- a/variants/unphone/variant.h +++ b/variants/unphone/variant.h @@ -57,9 +57,13 @@ #define LED_PIN 13 // the red part of the RGB LED #define LED_STATE_ON 0 // State when LED is lit -#define BUTTON_PIN 21 // Button 3 - square - top button in landscape mode -#define BUTTON_NEED_PULLUP // we do need a helping hand up -#define BUTTON_PIN_ALT 45 // Button 1 - triangle - bottom button in landscape mode +#define ALT_BUTTON_PIN 21 // Button 3 - square - top button in landscape mode +#define BUTTON_NEED_PULLUP2 TB_UP +#define BUTTON_PIN 0 // Circle button +#define BUTTON_NEED_PULLUP // we do need a helping hand up +#define CANCEL_BUTTON_PIN 45 // Button 1 - triangle - bottom button in landscape mode +#define CANCEL_BUTTON_ACTIVE_LOW true +#define CANCEL_BUTTON_ACTIVE_PULLUP true #define I2C_SDA 3 // I2C pins for this board #define I2C_SCL 4