From 21f8651f2b0b3a38025b02963569feb374e6e150 Mon Sep 17 00:00:00 2001 From: katethefox Date: Sun, 11 Jan 2026 12:40:20 -0600 Subject: [PATCH 01/10] Add rotary encoder scroll support for T-Lora Pager - Add RotaryEncoderInputDriver for quadrature encoder input - Add ScrollCallback mechanism to I2CKeyboardInputDriver - Implement alt+encoder scrolling in messages panel - When Sym key is held, encoder scrolls messages instead of navigating Co-Authored-By: Claude --- include/input/I2CKeyboardInputDriver.h | 21 ++ include/input/RotaryEncoderInputDriver.h | 26 ++ source/graphics/DeviceGUI.cpp | 18 +- source/graphics/TFT/TFTView_320x240.cpp | 107 +++++++- source/input/I2CKeyboardInputDriver.cpp | 285 +++++++++++++++++++++- source/input/RotaryEncoderInputDriver.cpp | 122 +++++++++ 6 files changed, 564 insertions(+), 15 deletions(-) create mode 100644 include/input/RotaryEncoderInputDriver.h create mode 100644 source/input/RotaryEncoderInputDriver.cpp diff --git a/include/input/I2CKeyboardInputDriver.h b/include/input/I2CKeyboardInputDriver.h index af5b7a80..d35dd8cf 100644 --- a/include/input/I2CKeyboardInputDriver.h +++ b/include/input/I2CKeyboardInputDriver.h @@ -5,6 +5,12 @@ #include #include +// Callback type for navigation events (e.g., backspace -> focus home button) +typedef void (*NavigationCallback)(void); + +// Callback type for scroll events (direction: +1 = down, -1 = up) +typedef void (*ScrollCallback)(int direction); + class I2CKeyboardInputDriver : public InputDriver { public: @@ -23,7 +29,22 @@ class I2CKeyboardInputDriver : public InputDriver using KeyboardList = std::list>; static KeyboardList &getI2CKeyboardList(void) { return i2cKeyboardList; } + // Navigation callback for backspace when not in text field + static void setNavigateHomeCallback(NavigationCallback cb) { navigateHomeCallback = cb; } + static NavigationCallback getNavigateHomeCallback(void) { return navigateHomeCallback; } + + // ALT modifier state for scroll-while-typing feature + static bool isAltModifierHeld(void) { return altModifierHeld; } + static void setAltModifierHeld(bool held) { altModifierHeld = held; } + + // Scroll callback for alt+encoder scrolling + static void setScrollCallback(ScrollCallback cb) { scrollCallback = cb; } + static ScrollCallback getScrollCallback(void) { return scrollCallback; } + protected: + static NavigationCallback navigateHomeCallback; + static bool altModifierHeld; + static ScrollCallback scrollCallback; bool registerI2CKeyboard(I2CKeyboardInputDriver *driver, std::string name, uint8_t address); private: diff --git a/include/input/RotaryEncoderInputDriver.h b/include/input/RotaryEncoderInputDriver.h new file mode 100644 index 00000000..b084f385 --- /dev/null +++ b/include/input/RotaryEncoderInputDriver.h @@ -0,0 +1,26 @@ +#pragma once + +#include "input/InputDriver.h" + +class RotaryEncoder; + +/** + * @brief Input driver for quadrature rotary encoders with push button. + * Uses the RotaryEncoder library for proper quadrature decoding. + * Designed for devices like T-Lora-Pager that have a scroll wheel. + */ +class RotaryEncoderInputDriver : public InputDriver +{ + public: + RotaryEncoderInputDriver(void); + virtual void init(void) override; + virtual void task_handler(void) override; + virtual ~RotaryEncoderInputDriver(void); + + protected: + static void encoder_read(lv_indev_t *indev, lv_indev_data_t *data); + + private: + static RotaryEncoder *rotary; + static volatile int16_t encoderDiff; +}; diff --git a/source/graphics/DeviceGUI.cpp b/source/graphics/DeviceGUI.cpp index 03a89fba..60b927d4 100644 --- a/source/graphics/DeviceGUI.cpp +++ b/source/graphics/DeviceGUI.cpp @@ -12,7 +12,10 @@ static I2CKeyboardInputDriver *keyboardDriver = nullptr; #include "input/LinuxInputDriver.h" static LinuxInputDriver *linuxInputDriver = nullptr; #else -#if defined(INPUTDRIVER_ENCODER_TYPE) +#if defined(INPUTDRIVER_ROTARY_TYPE) +#include "input/RotaryEncoderInputDriver.h" +static RotaryEncoderInputDriver *rotaryEncoderDriver = nullptr; +#elif defined(INPUTDRIVER_ENCODER_TYPE) #include "input/EncoderInputDriver.h" static EncoderInputDriver *encoderDriver = nullptr; #endif @@ -38,7 +41,9 @@ DeviceGUI::DeviceGUI(const DisplayDriverConfig *cfg, DisplayDriver *driver) : di // else // linuxInputDriver = InputDriver::instance(); #else -#if defined(INPUTDRIVER_ENCODER_TYPE) +#if defined(INPUTDRIVER_ROTARY_TYPE) + rotaryEncoderDriver = new RotaryEncoderInputDriver; +#elif defined(INPUTDRIVER_ENCODER_TYPE) encoderDriver = new EncoderInputDriver; #endif #if defined(INPUTDRIVER_MATRIX_TYPE) @@ -66,7 +71,10 @@ void DeviceGUI::init(IClientBase *client) if (linuxInputDriver) linuxInputDriver->init(); #endif -#if defined(INPUTDRIVER_ENCODER_TYPE) +#if defined(INPUTDRIVER_ROTARY_TYPE) + if (rotaryEncoderDriver) + rotaryEncoderDriver->init(); +#elif defined(INPUTDRIVER_ENCODER_TYPE) if (encoderDriver) encoderDriver->init(); #endif @@ -105,6 +113,10 @@ void DeviceGUI::task_handler(void) } #else displaydriver->task_handler(); +#if defined(INPUTDRIVER_ROTARY_TYPE) + if (rotaryEncoderDriver) + rotaryEncoderDriver->task_handler(); +#endif #endif }; diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index e65e60bf..3c6c9157 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -12,6 +12,7 @@ #include "graphics/view/TFT/Themes.h" #include "images.h" #include "input/InputDriver.h" +#include "input/I2CKeyboardInputDriver.h" #include "lv_i18n.h" #include "lvgl_private.h" #include "styles.h" @@ -454,13 +455,17 @@ void TFTView_320x240::init_screens(void) void TFTView_320x240::ui_set_active(lv_obj_t *b, lv_obj_t *p, lv_obj_t *tp) { if (activeButton) { + // Reset previous button styling lv_obj_set_style_border_width(activeButton, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(activeButton, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); if (Themes::get() == Themes::eDark) lv_obj_set_style_bg_img_recolor_opa(activeButton, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_img_recolor(activeButton, colorGray, LV_PART_MAIN | LV_STATE_DEFAULT); } + // Highlight new active button with green background and dark icon lv_obj_set_style_border_width(b, 3, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_obj_set_style_bg_img_recolor(b, colorMesh, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(b, colorMesh, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor(b, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_img_recolor_opa(b, 255, LV_PART_MAIN | LV_STATE_DEFAULT); if (activePanel) { @@ -500,6 +505,7 @@ void TFTView_320x240::ui_set_active(lv_obj_t *b, lv_obj_t *p, lv_obj_t *tp) activeButton = b; activePanel = p; if (activePanel == objects.messages_panel) { + // Always focus input area - KEY handler in ui_event_message_ready scrolls when empty lv_group_focus_obj(objects.message_input_area); } else if (inputdriver->hasKeyboardDevice() || inputdriver->hasEncoderDevice()) { setGroupFocus(activePanel); @@ -711,6 +717,36 @@ void TFTView_320x240::ui_events_init(void) lv_obj_add_event_cb(objects.map_button, this->ui_event_MapButton, LV_EVENT_ALL, NULL); lv_obj_add_event_cb(objects.settings_button, this->ui_event_SettingsButton, LV_EVENT_ALL, NULL); + // Focus handlers for main buttons (green highlight on focus) + lv_obj_add_event_cb(objects.home_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.home_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.nodes_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.nodes_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.groups_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.groups_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.messages_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.messages_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.map_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.map_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.settings_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.settings_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + + // Register callback for backspace -> home navigation + I2CKeyboardInputDriver::setNavigateHomeCallback([]() { + if (objects.home_button) { + lv_group_focus_obj(objects.home_button); + } + }); + + // Register callback for alt+encoder scrolling in messages + I2CKeyboardInputDriver::setScrollCallback([](int direction) { + if (THIS && THIS->activeMsgContainer && THIS->activePanel == objects.messages_panel) { + int32_t scroll_amount = 80; + // direction > 0 means scroll down (content moves up), < 0 means scroll up + lv_obj_scroll_by(THIS->activeMsgContainer, 0, direction > 0 ? -scroll_amount : scroll_amount, LV_ANIM_ON); + } + }); + // home buttons lv_obj_add_event_cb(objects.home_mail_button, this->ui_event_EnvelopeButton, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(objects.home_nodes_button, this->ui_event_OnlineNodesButton, LV_EVENT_ALL, NULL); @@ -989,6 +1025,43 @@ void TFTView_320x240::ui_event_BluetoothButton(lv_event_t *e) } } +// Focus handler for main menu buttons - applies green highlight on focus +void TFTView_320x240::ui_event_MainButtonFocus(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + lv_obj_t *btn = (lv_obj_t *)lv_event_get_target(e); + + if (event_code == LV_EVENT_FOCUSED) { + // Apply green highlight when button receives focus + lv_obj_set_style_bg_color(btn, colorMesh, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor(btn, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(btn, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + } else if (event_code == LV_EVENT_DEFOCUSED) { + // Remove highlight when focus leaves (unless it's the active button) + if (btn != THIS->activeButton) { + lv_obj_set_style_bg_color(btn, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); + if (Themes::get() == Themes::eDark) + lv_obj_set_style_bg_img_recolor_opa(btn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor(btn, colorGray, LV_PART_MAIN | LV_STATE_DEFAULT); + } + } +} + +// Global key handler for navigation - catches LV_KEY_HOME to focus side menu +void TFTView_320x240::ui_event_GlobalKeyHandler(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_KEY) { + uint32_t key = lv_event_get_key(e); + if (key == LV_KEY_HOME) { + // Focus the home button to enable side menu navigation + if (objects.home_button) { + lv_group_focus_obj(objects.home_button); + } + } + } +} + void TFTView_320x240::ui_event_NodesButton(lv_event_t *e) { static bool ignoreClicked = false; @@ -1689,6 +1762,33 @@ void TFTView_320x240::ui_event_message_ready(lv_event_t *e) lv_group_focus_obj(objects.message_input_area); } } + } else if (event_code == LV_EVENT_KEY) { + // Handle scrolling with encoder - safety checks to prevent boot loop + if (!THIS || !THIS->activeMsgContainer || THIS->activePanel != objects.messages_panel) { + return; + } + // Ensure message_input_area exists + if (!objects.message_input_area) { + return; + } + uint32_t key = lv_event_get_key(e); + // Only process UP/DOWN keys for scrolling + if (key != LV_KEY_UP && key != LV_KEY_DOWN) { + return; + } + // Scroll when textarea is empty OR when ALT modifier is held + bool altHeld = I2CKeyboardInputDriver::isAltModifierHeld(); + const char *txt = lv_textarea_get_text(objects.message_input_area); + bool isEmpty = (txt == nullptr) || (txt[0] == '\0'); + if (!altHeld && !isEmpty) { + return; // Let textarea handle cursor movement when typing (unless ALT held) + } + int32_t scroll_amount = 40; // pixels to scroll + if (key == LV_KEY_UP) { + lv_obj_scroll_by(THIS->activeMsgContainer, 0, scroll_amount, LV_ANIM_ON); + } else if (key == LV_KEY_DOWN) { + lv_obj_scroll_by(THIS->activeMsgContainer, 0, -scroll_amount, LV_ANIM_ON); + } } } @@ -6316,8 +6416,9 @@ lv_obj_t *TFTView_320x240::newMessageContainer(uint32_t from, uint32_t to, uint8 lv_obj_set_align(container, LV_ALIGN_TOP_MID); lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_clear_flag(container, lv_obj_flag_t(LV_OBJ_FLAG_PRESS_LOCK | LV_OBJ_FLAG_CLICK_FOCUSABLE | LV_OBJ_FLAG_GESTURE_BUBBLE | - LV_OBJ_FLAG_SNAPPABLE | LV_OBJ_FLAG_SCROLL_ELASTIC)); /// Flags + lv_obj_clear_flag(container, lv_obj_flag_t(LV_OBJ_FLAG_PRESS_LOCK | LV_OBJ_FLAG_GESTURE_BUBBLE | + LV_OBJ_FLAG_SNAPPABLE | LV_OBJ_FLAG_SCROLL_ELASTIC | + LV_OBJ_FLAG_CLICK_FOCUSABLE)); /// Flags lv_obj_set_scrollbar_mode(container, LV_SCROLLBAR_MODE_ACTIVE); lv_obj_set_scroll_dir(container, LV_DIR_VER); lv_obj_set_style_pad_left(container, 6, LV_PART_MAIN | LV_STATE_DEFAULT); diff --git a/source/input/I2CKeyboardInputDriver.cpp b/source/input/I2CKeyboardInputDriver.cpp index 84687bb0..acaaaff8 100644 --- a/source/input/I2CKeyboardInputDriver.cpp +++ b/source/input/I2CKeyboardInputDriver.cpp @@ -5,8 +5,12 @@ #include #include "indev/lv_indev_private.h" +#include "widgets/textarea/lv_textarea.h" I2CKeyboardInputDriver::KeyboardList I2CKeyboardInputDriver::i2cKeyboardList; +NavigationCallback I2CKeyboardInputDriver::navigateHomeCallback = nullptr; +bool I2CKeyboardInputDriver::altModifierHeld = false; +ScrollCallback I2CKeyboardInputDriver::scrollCallback = nullptr; I2CKeyboardInputDriver::I2CKeyboardInputDriver(void) {} @@ -103,46 +107,309 @@ void TDeckKeyboardInputDriver::readKeyboard(uint8_t address, lv_indev_t *indev, data->key = (uint32_t)keyValue; } +// ---------- TCA8418 Register Definitions ---------- +#define TCA8418_REG_CFG 0x01 +#define TCA8418_REG_INT_STAT 0x02 +#define TCA8418_REG_KEY_LCK_EC 0x03 +#define TCA8418_REG_KEY_EVENT_A 0x04 +#define TCA8418_REG_KP_GPIO_1 0x1D +#define TCA8418_REG_KP_GPIO_2 0x1E +#define TCA8418_REG_KP_GPIO_3 0x1F +#define TCA8418_REG_GPIO_DIR_1 0x23 +#define TCA8418_REG_GPIO_DIR_2 0x24 +#define TCA8418_REG_GPIO_DIR_3 0x25 +#define TCA8418_REG_GPI_EM_1 0x20 +#define TCA8418_REG_GPI_EM_2 0x21 +#define TCA8418_REG_GPI_EM_3 0x22 +#define TCA8418_REG_GPIO_INT_LVL_1 0x26 +#define TCA8418_REG_GPIO_INT_LVL_2 0x27 +#define TCA8418_REG_GPIO_INT_LVL_3 0x28 +#define TCA8418_REG_GPIO_INT_EN_1 0x1A +#define TCA8418_REG_GPIO_INT_EN_2 0x1B +#define TCA8418_REG_GPIO_INT_EN_3 0x1C +#define TCA8418_REG_DEBOUNCE_DIS_1 0x29 +#define TCA8418_REG_DEBOUNCE_DIS_2 0x2A +#define TCA8418_REG_DEBOUNCE_DIS_3 0x2B + +// Helper to write a register +static void tca8418WriteReg(uint8_t address, uint8_t reg, uint8_t value) +{ + Wire.beginTransmission(address); + Wire.write(reg); + Wire.write(value); + Wire.endTransmission(); +} + +// Helper to read a register +static uint8_t tca8418ReadReg(uint8_t address, uint8_t reg) +{ + Wire.beginTransmission(address); + Wire.write(reg); + Wire.endTransmission(); + Wire.requestFrom(address, (uint8_t)1); + if (Wire.available()) { + return Wire.read(); + } + return 0; +} + // ---------- TCA8418KeyboardInputDriver Implementation ---------- +static uint8_t tca8418Address = 0x34; + TCA8418KeyboardInputDriver::TCA8418KeyboardInputDriver(uint8_t address) { + tca8418Address = address; registerI2CKeyboard(this, "TCA8418 Keyboard", address); } void TCA8418KeyboardInputDriver::init(void) { - // Additional initialization for TCA8418 if needed I2CKeyboardInputDriver::init(); + + // Initialize TCA8418 - set up keyboard matrix + ILOG_DEBUG("TCA8418 init at address 0x%02X", tca8418Address); + + // Set all GPIO pins to input + tca8418WriteReg(tca8418Address, TCA8418_REG_GPIO_DIR_1, 0x00); + tca8418WriteReg(tca8418Address, TCA8418_REG_GPIO_DIR_2, 0x00); + tca8418WriteReg(tca8418Address, TCA8418_REG_GPIO_DIR_3, 0x00); + + // Add all pins to key events + tca8418WriteReg(tca8418Address, TCA8418_REG_GPI_EM_1, 0xFF); + tca8418WriteReg(tca8418Address, TCA8418_REG_GPI_EM_2, 0xFF); + tca8418WriteReg(tca8418Address, TCA8418_REG_GPI_EM_3, 0xFF); + + // Set all pins to falling edge interrupts + tca8418WriteReg(tca8418Address, TCA8418_REG_GPIO_INT_LVL_1, 0x00); + tca8418WriteReg(tca8418Address, TCA8418_REG_GPIO_INT_LVL_2, 0x00); + tca8418WriteReg(tca8418Address, TCA8418_REG_GPIO_INT_LVL_3, 0x00); + + // Enable interrupts for all pins + tca8418WriteReg(tca8418Address, TCA8418_REG_GPIO_INT_EN_1, 0xFF); + tca8418WriteReg(tca8418Address, TCA8418_REG_GPIO_INT_EN_2, 0xFF); + tca8418WriteReg(tca8418Address, TCA8418_REG_GPIO_INT_EN_3, 0xFF); + + // Enable debounce + tca8418WriteReg(tca8418Address, TCA8418_REG_DEBOUNCE_DIS_1, 0x00); + tca8418WriteReg(tca8418Address, TCA8418_REG_DEBOUNCE_DIS_2, 0x00); + tca8418WriteReg(tca8418Address, TCA8418_REG_DEBOUNCE_DIS_3, 0x00); + + // Flush any pending key events + while (tca8418ReadReg(tca8418Address, TCA8418_REG_KEY_EVENT_A) != 0) { + // Keep reading until FIFO is empty + } + + // Clear interrupt status + tca8418WriteReg(tca8418Address, TCA8418_REG_INT_STAT, 0x03); + + // Enable key event interrupt (critical for key FIFO to work) + uint8_t cfg = tca8418ReadReg(tca8418Address, TCA8418_REG_CFG); + cfg |= 0x01; // KE_IEN - Key events interrupt enable + tca8418WriteReg(tca8418Address, TCA8418_REG_CFG, cfg); + + ILOG_INFO("TCA8418 keyboard initialized (CFG=0x%02X)", cfg); } void TCA8418KeyboardInputDriver::readKeyboard(uint8_t address, lv_indev_t *indev, lv_indev_data_t *data) { - // TODO - char keyValue = 0; data->state = LV_INDEV_STATE_RELEASED; - data->key = (uint32_t)keyValue; + data->key = 0; + + // Read key count from KEY_LCK_EC register (bits 0-3) + Wire.beginTransmission(address); + Wire.write(TCA8418_REG_KEY_LCK_EC); + Wire.endTransmission(); + Wire.requestFrom(address, (uint8_t)1); + if (Wire.available()) { + uint8_t keyCount = Wire.read() & 0x0F; + if (keyCount > 0) { + // Read key event from FIFO + Wire.beginTransmission(address); + Wire.write(TCA8418_REG_KEY_EVENT_A); + Wire.endTransmission(); + Wire.requestFrom(address, (uint8_t)1); + if (Wire.available()) { + uint8_t keyEvent = Wire.read(); + uint8_t keyCode = keyEvent & 0x7F; + bool pressed = (keyEvent & 0x80) != 0; + + if (pressed && keyCode > 0) { + data->state = LV_INDEV_STATE_PRESSED; + data->key = keyCode; // Will be mapped by subclass + ILOG_DEBUG("TCA8418 key event: code=%d pressed=%d", keyCode, pressed); + } + } + } + } } // ---------- TLoraPagerKeyboardInputDriver Implementation ---------- +// T-Pager keyboard layout: 4 rows x 10 columns = 31 keys +// Key mapping from TCA8418 key codes to characters [normal, shift, sym] +static const char TLoraPagerKeyMap[31][3] = { + {'q', 'Q', '1'}, // Key 1 + {'w', 'W', '2'}, // Key 2 + {'e', 'E', '3'}, // Key 3 + {'r', 'R', '4'}, // Key 4 + {'t', 'T', '5'}, // Key 5 + {'y', 'Y', '6'}, // Key 6 + {'u', 'U', '7'}, // Key 7 + {'i', 'I', '8'}, // Key 8 + {'o', 'O', '9'}, // Key 9 + {'p', 'P', '0'}, // Key 10 + {'a', 'A', '*'}, // Key 11 + {'s', 'S', '/'}, // Key 12 + {'d', 'D', '+'}, // Key 13 + {'f', 'F', '-'}, // Key 14 + {'g', 'G', '='}, // Key 15 + {'h', 'H', ':'}, // Key 16 + {'j', 'J', '\''}, // Key 17 + {'k', 'K', '"'}, // Key 18 + {'l', 'L', '@'}, // Key 19 + {0x0D, 0x09, 0x0D}, // Key 20: Enter, Tab (shift), Enter (sym) + {0, 0, 0}, // Key 21: Sym modifier (no output) + {'z', 'Z', '_'}, // Key 22 + {'x', 'X', '$'}, // Key 23 + {'c', 'C', ';'}, // Key 24 + {'v', 'V', '?'}, // Key 25 + {'b', 'B', '!'}, // Key 26 + {'n', 'N', ','}, // Key 27 + {'m', 'M', '.'}, // Key 28 + {0, 0, 0}, // Key 29: Shift modifier (no output) + {0x08, 0x08, 0x1B}, // Key 30: Backspace, Backspace (shift), ESC (sym) + {' ', ' ', ' '} // Key 31: Space +}; + +// Modifier key indices (0-based) +static const uint8_t MODIFIER_SYM_KEY = 20; // Key 21 +static const uint8_t MODIFIER_SHIFT_KEY = 28; // Key 29 + +// Modifier state (sticky toggles) +static uint8_t modifierState = 0; // 0=normal, 1=shift, 2=sym + TLoraPagerKeyboardInputDriver::TLoraPagerKeyboardInputDriver(uint8_t address) : TCA8418KeyboardInputDriver(address) { - registerI2CKeyboard(this, "TLora Pager Keyboard", address); + // Don't register here - parent TCA8418KeyboardInputDriver already registers } void TLoraPagerKeyboardInputDriver::init(void) { - // Additional initialization for TLora-Pager if needed TCA8418KeyboardInputDriver::init(); + + // Set up T-Pager keyboard matrix: 4 rows x 10 columns + // Rows 0-3 (bits 0-3 in KP_GPIO_1) + tca8418WriteReg(tca8418Address, TCA8418_REG_KP_GPIO_1, 0x0F); + // Columns 0-7 (bits 0-7 in KP_GPIO_2) + tca8418WriteReg(tca8418Address, TCA8418_REG_KP_GPIO_2, 0xFF); + // Columns 8-9 (bits 0-1 in KP_GPIO_3) + tca8418WriteReg(tca8418Address, TCA8418_REG_KP_GPIO_3, 0x03); + + ILOG_INFO("TLoraPagerKeyboardInputDriver initialized (4x10 matrix)"); } +static uint32_t lastKeyboardLog = 0; + void TLoraPagerKeyboardInputDriver::readKeyboard(uint8_t address, lv_indev_t *indev, lv_indev_data_t *data) { - // TODO - char keyValue = 0; data->state = LV_INDEV_STATE_RELEASED; - data->key = (uint32_t)keyValue; + data->key = 0; + + // Read key count from KEY_LCK_EC register (bits 0-3) + Wire.beginTransmission(address); + Wire.write(TCA8418_REG_KEY_LCK_EC); + Wire.endTransmission(); + Wire.requestFrom(address, (uint8_t)1); + if (Wire.available()) { + uint8_t keyCount = Wire.read() & 0x0F; + + // Log periodically to show we're polling + uint32_t now = millis(); + if (now - lastKeyboardLog > 5000) { + ILOG_DEBUG("T-Pager keyboard poll: keyCount=%d mod=%d", keyCount, modifierState); + lastKeyboardLog = now; + } + if (keyCount > 0) { + // Read key event from FIFO + Wire.beginTransmission(address); + Wire.write(TCA8418_REG_KEY_EVENT_A); + Wire.endTransmission(); + Wire.requestFrom(address, (uint8_t)1); + if (Wire.available()) { + uint8_t keyEvent = Wire.read(); + uint8_t keyCode = keyEvent & 0x7F; + bool pressed = (keyEvent & 0x80) != 0; + + if (pressed && keyCode > 0 && keyCode <= 31) { + uint8_t keyIndex = keyCode - 1; + + // Check for modifier keys + if (keyIndex == MODIFIER_SHIFT_KEY) { + // Toggle shift modifier + modifierState = (modifierState == 1) ? 0 : 1; + ILOG_DEBUG("T-Pager: Shift toggled, modifierState=%d", modifierState); + return; // Don't output a key for modifier press + } + if (keyIndex == MODIFIER_SYM_KEY) { + // Toggle sym modifier + modifierState = (modifierState == 2) ? 0 : 2; + // Also update the class-accessible ALT state for scroll-while-typing + I2CKeyboardInputDriver::setAltModifierHeld(modifierState == 2); + ILOG_DEBUG("T-Pager: Sym toggled, modifierState=%d altHeld=%d", modifierState, modifierState == 2); + return; // Don't output a key for modifier press + } + + // Get character based on modifier state + char keyChar = TLoraPagerKeyMap[keyIndex][modifierState]; + + if (keyChar != 0) { + data->state = LV_INDEV_STATE_PRESSED; + + // Map special keys to LVGL key codes + switch (keyChar) { + case 0x0D: // Enter + data->key = LV_KEY_ENTER; + break; + case 0x09: // Tab + data->key = LV_KEY_NEXT; + break; + case 0x08: // Backspace + { + // Check if focused object is a textarea + lv_obj_t *focused = lv_group_get_focused(lv_group_get_default()); + if (focused && lv_obj_check_type(focused, &lv_textarea_class)) { + // In textarea - send backspace as normal + data->key = LV_KEY_BACKSPACE; + } else { + // Not in textarea - call navigation callback to focus home button + if (I2CKeyboardInputDriver::navigateHomeCallback) { + I2CKeyboardInputDriver::navigateHomeCallback(); + data->state = LV_INDEV_STATE_RELEASED; + ILOG_DEBUG("Backspace: called navigateHomeCallback"); + return; + } + // Fallback to ESC if no callback registered + data->key = LV_KEY_ESC; + } + } + break; + case 0x1B: // ESC + data->key = LV_KEY_ESC; + break; + default: + data->key = (uint32_t)keyChar; + break; + } + ILOG_DEBUG("T-Pager key: code=%d mod=%d char='%c' lvkey=%d", keyCode, modifierState, keyChar, data->key); + + // Clear modifier after a regular key press (one-shot behavior) + modifierState = 0; + } + } + } + } + } } // ---------- TDeckProKeyboardInputDriver Implementation ---------- diff --git a/source/input/RotaryEncoderInputDriver.cpp b/source/input/RotaryEncoderInputDriver.cpp new file mode 100644 index 00000000..166eab37 --- /dev/null +++ b/source/input/RotaryEncoderInputDriver.cpp @@ -0,0 +1,122 @@ +#ifdef INPUTDRIVER_ROTARY_TYPE + +#include "input/RotaryEncoderInputDriver.h" +#include "input/I2CKeyboardInputDriver.h" +#include "RotaryEncoder.h" +#include "Arduino.h" +#include "util/ILog.h" + +// Static member initialization +RotaryEncoder *RotaryEncoderInputDriver::rotary = nullptr; +volatile int16_t RotaryEncoderInputDriver::encoderDiff = 0; + +RotaryEncoderInputDriver::RotaryEncoderInputDriver(void) {} + +RotaryEncoderInputDriver::~RotaryEncoderInputDriver(void) +{ + if (rotary) { + delete rotary; + rotary = nullptr; + } +} + +void RotaryEncoderInputDriver::init(void) +{ + ILOG_DEBUG("RotaryEncoderInputDriver init..."); + +#if defined(INPUTDRIVER_ROTARY_UP) && defined(INPUTDRIVER_ROTARY_DOWN) + // Create the RotaryEncoder instance + // ROTARY_UP is pin A, ROTARY_DOWN is pin B in quadrature terms +#ifdef INPUTDRIVER_ROTARY_BTN + rotary = new RotaryEncoder(INPUTDRIVER_ROTARY_UP, INPUTDRIVER_ROTARY_DOWN, INPUTDRIVER_ROTARY_BTN); +#else + rotary = new RotaryEncoder(INPUTDRIVER_ROTARY_UP, INPUTDRIVER_ROTARY_DOWN); +#endif +#endif + + if (!rotary) { + ILOG_ERROR("RotaryEncoderInputDriver: Failed to create RotaryEncoder - check pin definitions"); + return; + } + + // Create LVGL encoder input device + encoder = lv_indev_create(); + lv_indev_set_type(encoder, LV_INDEV_TYPE_ENCODER); + lv_indev_set_read_cb(encoder, encoder_read); + + // Create or use existing input group + if (!inputGroup) { + inputGroup = lv_group_create(); + lv_group_set_default(inputGroup); + } + lv_indev_set_group(encoder, inputGroup); + + ILOG_INFO("RotaryEncoderInputDriver initialized (pins A=%d, B=%d, BTN=%d)", + INPUTDRIVER_ROTARY_UP, INPUTDRIVER_ROTARY_DOWN, +#ifdef INPUTDRIVER_ROTARY_BTN + INPUTDRIVER_ROTARY_BTN +#else + -1 +#endif + ); +} + +void RotaryEncoderInputDriver::task_handler(void) +{ + if (!rotary) + return; + + // Process rotary encoder - must be called frequently for proper quadrature decoding + RotaryEncoder::Direction dir = rotary->process(); + + if (dir == RotaryEncoder::DIRECTION_CW) { + encoderDiff++; + } else if (dir == RotaryEncoder::DIRECTION_CCW) { + encoderDiff--; + } +} + +void RotaryEncoderInputDriver::encoder_read(lv_indev_t *indev, lv_indev_data_t *data) +{ + if (!rotary) { + data->state = LV_INDEV_STATE_RELEASED; + data->enc_diff = 0; + return; + } + + // Default state + data->state = LV_INDEV_STATE_RELEASED; + + // Check if alt is held and we have encoder movement - scroll instead of navigate + if (I2CKeyboardInputDriver::isAltModifierHeld() && encoderDiff != 0) { + auto scrollCb = I2CKeyboardInputDriver::getScrollCallback(); + if (scrollCb) { + // Positive diff = clockwise = scroll down, negative = counter-clockwise = scroll up + scrollCb(encoderDiff > 0 ? 1 : -1); + ILOG_DEBUG("Alt+encoder scroll: diff=%d", encoderDiff); + } + encoderDiff = 0; // Consume the input + data->enc_diff = 0; + return; + } + + // Normal encoder behavior - navigation + data->enc_diff = encoderDiff; + encoderDiff = 0; + + // Button handling +#ifdef INPUTDRIVER_ROTARY_BTN + uint8_t btnState = rotary->readButton(); + + if (btnState == RotaryEncoder::BUTTON_PRESSED) { + data->key = LV_KEY_ENTER; + data->state = LV_INDEV_STATE_PRESSED; + } else if (btnState == RotaryEncoder::BUTTON_PRESSED_RELEASED) { + // Button was pressed and released - reset for next press + rotary->resetButton(); + data->state = LV_INDEV_STATE_RELEASED; + } +#endif +} + +#endif From 5313e6c3cb234be1b06d6bdaf8edf8222b232bb8 Mon Sep 17 00:00:00 2001 From: katethefox Date: Sun, 11 Jan 2026 12:45:01 -0600 Subject: [PATCH 02/10] T-Lora Pager keyboard and UI improvements - Add TFTView header declarations for focus event handlers - Update I2CKeyboardScanner to avoid I2C conflicts on T-Lora Pager - Add LGFXDriver improvements - Update library.json dependencies - Minor TFTView_480x222 fix Co-Authored-By: Claude --- include/graphics/driver/LGFXDriver.h | 17 +++++++++++++++++ include/graphics/view/TFT/TFTView_320x240.h | 2 ++ library.json | 3 ++- source/graphics/TFT/TFTView_480x222.cpp | 2 +- source/input/I2CKeyboardScanner.cpp | 21 +++++++++++++++++++-- 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/include/graphics/driver/LGFXDriver.h b/include/graphics/driver/LGFXDriver.h index 3b2a09ee..b709e1d5 100644 --- a/include/graphics/driver/LGFXDriver.h +++ b/include/graphics/driver/LGFXDriver.h @@ -178,7 +178,24 @@ template void LGFXDriver::display_flush(lv_display_t *disp, c uint32_t w = lv_area_get_width(area); uint32_t h = lv_area_get_height(area); lv_draw_sw_rgb565_swap(px_map, w * h); +#if defined(T_LORA_PAGER) && defined(VIEW_320x240) + // Scale 320x240 UI to fill 480x222 display + // X scale: 480/320 = 1.5, Y scale: 222/240 = 0.925 + constexpr float scale_x = 480.0f / 320.0f; // 1.5 + constexpr float scale_y = 222.0f / 240.0f; // 0.925 + int32_t dst_x = (int32_t)(area->x1 * scale_x); + int32_t dst_y = (int32_t)(area->y1 * scale_y); + int32_t dst_w = (int32_t)(w * scale_x); + int32_t dst_h = (int32_t)(h * scale_y); + // Use pushImageAffine for scaled output + float affine[6] = { + scale_x, 0, (float)dst_x, + 0, scale_y, (float)dst_y + }; + lgfx->pushImageAffine(affine, w, h, (uint16_t *)px_map); +#else lgfx->pushImage(area->x1, area->y1, w, h, (uint16_t *)px_map); +#endif lv_display_flush_ready(disp); } #else diff --git a/include/graphics/view/TFT/TFTView_320x240.h b/include/graphics/view/TFT/TFTView_320x240.h index ba6cb4bf..159bb0d8 100644 --- a/include/graphics/view/TFT/TFTView_320x240.h +++ b/include/graphics/view/TFT/TFTView_320x240.h @@ -274,6 +274,8 @@ class TFTView_320x240 : public MeshtasticView static void ui_event_BluetoothButton(lv_event_t *e); // static void ui_event_HomeButton(lv_event_t * e); + static void ui_event_MainButtonFocus(lv_event_t *e); + static void ui_event_GlobalKeyHandler(lv_event_t *e); static void ui_event_NodesButton(lv_event_t *e); static void ui_event_GroupsButton(lv_event_t *e); static void ui_event_MessagesButton(lv_event_t *e); diff --git a/library.json b/library.json index 627c5be9..5769a386 100644 --- a/library.json +++ b/library.json @@ -23,7 +23,8 @@ "ArduinoThread": "https://github.com/meshtastic/ArduinoThread/archive/7c3ee9e1951551b949763b1f5280f8db1fa4068d.zip", "lvgl/lvgl": "9.3.0", "greiman/SdFat": "https://github.com/mverch67/SdFat/archive/152a52251fc5e1d581303b42378ea712ab229246.zip", - "nanopb/Nanopb": "0.4.91" + "nanopb/Nanopb": "0.4.91", + "RotaryEncoder": "https://github.com/mverch67/RotaryEncoder/archive/da958a21389cbcd485989705df602a33e092dd88.zip" }, "build": { "libArchive": true, diff --git a/source/graphics/TFT/TFTView_480x222.cpp b/source/graphics/TFT/TFTView_480x222.cpp index 90b550d0..dba862bb 100644 --- a/source/graphics/TFT/TFTView_480x222.cpp +++ b/source/graphics/TFT/TFTView_480x222.cpp @@ -14,7 +14,7 @@ TFTView_480x222 *TFTView_480x222::gui = nullptr; TFTView_480x222 *TFTView_480x222::instance(void) { if (!gui) - gui = new TFTView_480x222(nullptr, DisplayDriverFactory::create(480, 320)); + gui = new TFTView_480x222(nullptr, DisplayDriverFactory::create(480, 222)); return gui; } diff --git a/source/input/I2CKeyboardScanner.cpp b/source/input/I2CKeyboardScanner.cpp index 9ad6ea88..a09e9659 100644 --- a/source/input/I2CKeyboardScanner.cpp +++ b/source/input/I2CKeyboardScanner.cpp @@ -18,33 +18,50 @@ I2CKeyboardInputDriver *I2CKeyboardScanner::scan(void) { I2CKeyboardInputDriver *driver = nullptr; #ifndef ARCH_PORTDUINO + +#if defined(T_LORA_PAGER) + // T-Lora Pager only has TCA8418 keyboard at 0x34 + // Don't scan other addresses - they conflict with BQ27220 (0x55) and DRV2605 (0x5A) + uint8_t i2cKeyboards[] = {SCAN_TCA8418_KB_ADDR}; +#else uint8_t i2cKeyboards[] = {SCAN_TDECK_KB_ADDR, SCAN_TCA8418_KB_ADDR, SCAN_CARDKB_ADDR, SCAN_BBQ10_KB_ADDR, SCAN_MPR121_KB_ADDR}; - ILOG_DEBUG("I2CKeyboardScanner scanning..."); +#endif + + ILOG_INFO("I2CKeyboardScanner scanning for keyboards..."); for (uint8_t i = 0; i < sizeof(i2cKeyboards); i++) { uint8_t address = i2cKeyboards[i]; Wire.beginTransmission(address); - if (Wire.endTransmission() == 0) { + uint8_t error = Wire.endTransmission(); + ILOG_DEBUG(" Scanning 0x%02X: %s", address, error == 0 ? "FOUND" : "not found"); + if (error == 0) { switch (address) { case SCAN_TDECK_KB_ADDR: + ILOG_INFO("Found T-Deck keyboard at 0x%02X", address); driver = new TDeckKeyboardInputDriver(address); break; case SCAN_TCA8418_KB_ADDR: #if defined(T_LORA_PAGER) + ILOG_INFO("Found TCA8418 keyboard at 0x%02X (T-Lora Pager)", address); driver = new TLoraPagerKeyboardInputDriver(address); #elif defined(T_DECK_PRO) + ILOG_INFO("Found TCA8418 keyboard at 0x%02X (T-Deck Pro)", address); driver = new TDeckProKeyboardInputDriver(address); #else + ILOG_INFO("Found TCA8418 keyboard at 0x%02X", address); driver = new TCA8418KeyboardInputDriver(address); #endif break; case SCAN_CARDKB_ADDR: + ILOG_INFO("Found CardKB at 0x%02X", address); driver = new CardKBInputDriver(address); break; case SCAN_BBQ10_KB_ADDR: + ILOG_INFO("Found BBQ10 keyboard at 0x%02X", address); driver = new BBQ10KeyboardInputDriver(address); break; case SCAN_MPR121_KB_ADDR: + ILOG_INFO("Found MPR121 keyboard at 0x%02X", address); driver = new MPR121KeyboardInputDriver(address); break; default: From d081ae535941a2b483f0f7ea3c1ed58ff11e63d6 Mon Sep 17 00:00:00 2001 From: katethefox Date: Sun, 11 Jan 2026 13:46:12 -0600 Subject: [PATCH 03/10] Add native 480x222 UI and alert auto-dismiss for T-Lora Pager - Implement full TFTView_480x222 class with native 480x222 resolution - Remove non-uniform scaling code from LGFXDriver.h (was 1.5x/0.925x) - Add VIEW_480x222 to Themes.cpp compilation conditions - Add 3-second auto-dismiss timer to messageAlert() for both views This eliminates the stretched/distorted UI appearance and makes the "No map tiles found" alert automatically disappear after 3 seconds. Co-Authored-By: Claude Opus 4.5 --- include/graphics/driver/LGFXDriver.h | 17 - include/graphics/view/TFT/TFTView_480x222.h | 431 +- source/graphics/TFT/TFTView_320x240.cpp | 10 +- source/graphics/TFT/TFTView_480x222.cpp | 7447 ++++++++++++++++++- source/graphics/TFT/Themes.cpp | 2 +- 5 files changed, 7869 insertions(+), 38 deletions(-) diff --git a/include/graphics/driver/LGFXDriver.h b/include/graphics/driver/LGFXDriver.h index b709e1d5..3b2a09ee 100644 --- a/include/graphics/driver/LGFXDriver.h +++ b/include/graphics/driver/LGFXDriver.h @@ -178,24 +178,7 @@ template void LGFXDriver::display_flush(lv_display_t *disp, c uint32_t w = lv_area_get_width(area); uint32_t h = lv_area_get_height(area); lv_draw_sw_rgb565_swap(px_map, w * h); -#if defined(T_LORA_PAGER) && defined(VIEW_320x240) - // Scale 320x240 UI to fill 480x222 display - // X scale: 480/320 = 1.5, Y scale: 222/240 = 0.925 - constexpr float scale_x = 480.0f / 320.0f; // 1.5 - constexpr float scale_y = 222.0f / 240.0f; // 0.925 - int32_t dst_x = (int32_t)(area->x1 * scale_x); - int32_t dst_y = (int32_t)(area->y1 * scale_y); - int32_t dst_w = (int32_t)(w * scale_x); - int32_t dst_h = (int32_t)(h * scale_y); - // Use pushImageAffine for scaled output - float affine[6] = { - scale_x, 0, (float)dst_x, - 0, scale_y, (float)dst_y - }; - lgfx->pushImageAffine(affine, w, h, (uint16_t *)px_map); -#else lgfx->pushImage(area->x1, area->y1, w, h, (uint16_t *)px_map); -#endif lv_display_flush_ready(disp); } #else diff --git a/include/graphics/view/TFT/TFTView_480x222.h b/include/graphics/view/TFT/TFTView_480x222.h index 72a5d901..c3e76c23 100644 --- a/include/graphics/view/TFT/TFTView_480x222.h +++ b/include/graphics/view/TFT/TFTView_480x222.h @@ -1,9 +1,13 @@ #pragma once #include "graphics/common/MeshtasticView.h" +#include "meshtastic/clientonly.pb.h" +#include + +class MapPanel; /** - * @brief GUI view for e.g. unPhone or WT32-SC01 Plus + * @brief GUI view for T-Lora Pager (480x222 display) * Handles creation of display driver and controller. * Note: due to static callbacks in lvgl this class is modelled as * a singleton with static callback members @@ -12,18 +16,191 @@ class TFTView_480x222 : public MeshtasticView { public: void init(IClientBase *client) override; + bool setupUIConfig(const meshtastic_DeviceUIConfig &uiconfig) override; void task_handler(void) override; - void addOrUpdateNode(uint32_t nodeNum, uint8_t channel, uint32_t lastHeard, const meshtastic_User &cfg) override {} + // methods to update view + void setMyInfo(uint32_t nodeNum) override; + void setDeviceMetaData(int hw_model, const char *version, bool has_bluetooth, bool has_wifi, bool has_eth, + bool can_shutdown) override; + void addOrUpdateNode(uint32_t nodeNum, uint8_t channel, uint32_t lastHeard, const meshtastic_User &cfg) override; void addNode(uint32_t nodeNum, uint8_t channel, const char *userShort, const char *userLong, uint32_t lastHeard, eRole role, - bool hasKey, bool viaMqtt) override - { - } - void updateNode(uint32_t nodeNum, uint8_t channel, const meshtastic_User &cfg) override {} + bool hasKey, bool unmessagable) override; + void updateNode(uint32_t nodeNum, uint8_t channel, const meshtastic_User &cfg) override; + void updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, int32_t alt, uint32_t sats, uint32_t precision) override; + void updateMetrics(uint32_t nodeNum, uint32_t bat_level, float voltage, float chUtil, float airUtil) override; + void updateEnvironmentMetrics(uint32_t nodeNum, const meshtastic_EnvironmentMetrics &metrics) override; + void updateAirQualityMetrics(uint32_t nodeNum, const meshtastic_AirQualityMetrics &metrics) override; + void updatePowerMetrics(uint32_t nodeNum, const meshtastic_PowerMetrics &metrics) override; + void updateSignalStrength(uint32_t nodeNum, int32_t rssi, float snr) override; + void updateHopsAway(uint32_t nodeNum, uint8_t hopsAway) override; + void updateConnectionStatus(const meshtastic_DeviceConnectionStatus &status) override; + + // methods to update device config + void updateChannelConfig(const meshtastic_Channel &ch) override; + void updateDeviceConfig(const meshtastic_Config_DeviceConfig &cfg) override; + void updatePositionConfig(const meshtastic_Config_PositionConfig &cfg) override; + void updatePowerConfig(const meshtastic_Config_PowerConfig &cfg) override; + void updateNetworkConfig(const meshtastic_Config_NetworkConfig &cfg) override; + void updateDisplayConfig(const meshtastic_Config_DisplayConfig &cfg) override; + void updateLoRaConfig(const meshtastic_Config_LoRaConfig &cfg) override; + void updateBluetoothConfig(const meshtastic_Config_BluetoothConfig &cfg, uint32_t id = 0) override; + void updateSecurityConfig(const meshtastic_Config_SecurityConfig &cfg) override; + void updateSessionKeyConfig(const meshtastic_Config_SessionkeyConfig &cfg) override; + + // methods to update module config + void updateMQTTModule(const meshtastic_ModuleConfig_MQTTConfig &cfg) override; + void updateSerialModule(const meshtastic_ModuleConfig_SerialConfig &cfg) override {} + void updateExtNotificationModule(const meshtastic_ModuleConfig_ExternalNotificationConfig &cfg) override; + void updateStoreForwardModule(const meshtastic_ModuleConfig_StoreForwardConfig &cfg) override {} + void updateRangeTestModule(const meshtastic_ModuleConfig_RangeTestConfig &cfg) override {} + void updateTelemetryModule(const meshtastic_ModuleConfig_TelemetryConfig &cfg) override {} + void updateCannedMessageModule(const meshtastic_ModuleConfig_CannedMessageConfig &) override {} + void updateAudioModule(const meshtastic_ModuleConfig_AudioConfig &cfg) override {} + void updateRemoteHardwareModule(const meshtastic_ModuleConfig_RemoteHardwareConfig &cfg) override {} + void updateNeighborInfoModule(const meshtastic_ModuleConfig_NeighborInfoConfig &cfg) override {} + void updateAmbientLightingModule(const meshtastic_ModuleConfig_AmbientLightingConfig &cfg) override {} + void updateDetectionSensorModule(const meshtastic_ModuleConfig_DetectionSensorConfig &cfg) override {} + void updatePaxCounterModule(const meshtastic_ModuleConfig_PaxcounterConfig &cfg) override {} + void updateFileinfo(const meshtastic_FileInfo &fileinfo) override {} + void updateRingtone(const char rtttl[231]) override; + + // update internal time + void updateTime(uint32_t time) override; + + void packetReceived(const meshtastic_MeshPacket &p) override; + void handleResponse(uint32_t from, uint32_t id, const meshtastic_Routing &routing, const meshtastic_MeshPacket &p) override; + void handleResponse(uint32_t from, uint32_t id, const meshtastic_RouteDiscovery &route) override; + void handlePositionResponse(uint32_t from, uint32_t request_id, int32_t rx_rssi, float rx_snr, bool isNeighbor) override; + void notifyRestoreMessages(int32_t percentage) override; + void notifyMessagesRestored(void) override; + void notifyConnected(const char *info) override; + void notifyDisconnected(const char *info) override; + void notifyResync(bool show) override; + void notifyReboot(bool show) override; + void notifyShutdown(void) override; + void blankScreen(bool enable) override; + void screenSaving(bool enabled) override; + bool isScreenLocked(void) override; + void newMessage(uint32_t from, uint32_t to, uint8_t ch, const char *msg, uint32_t &msgtime, bool restore = true) override; + void restoreMessage(const LogMessage &msg) override; + void removeNode(uint32_t nodeNum) override; + + enum BasicSettings { + eNone, + eSetup, + eUsername, + eDeviceRole, + eRegion, + eModemPreset, + eChannel, + eWifi, + eLanguage, + eScreenTimeout, + eScreenLock, + eScreenBrightness, + eTheme, + eInputControl, + eAlertBuzzer, + eBackupRestore, + eReset, + eReboot, + eDisplayMode, + eModifyChannel + }; protected: - virtual void addMessage(char *msg) {} - virtual void newMessage(uint32_t nodeNum, lv_obj_t *container, uint8_t channel, const char *msg) {} + struct NodeFilter { + bool unknown; // filter out unknown nodes + bool mqtt; // filter out via mqtt nodes + bool offline; // filter out offline nodes (>15min lastheard) + bool position; // filter out nodes without position + char *name; // filter by name + bool active; // flag for active filter + }; + + struct NodeHighlight { + bool chat; // highlight nodes with active chats + bool position; // highlight nodes with position + bool telemetry; // highlight nodes with telemetry + bool iaq; // highlight nodes with IAQ + char *name; // hightlight by name + bool active; // flag for active highlight; + }; + + typedef void (*UserWidgetFunc)(lv_obj_t *, void *, int); + + // initialize all ui screens + virtual void init_screens(void); + // update custom display string on boot screen + virtual void updateBootMessage(const char *); + // show initial setup panel to configure region and name + virtual void requestSetup(void); + // patch widgets on generated screens + virtual void apply_hotfix(void); + // update node counter display (online and filtered) + virtual void updateNodesStatus(void); + // display message popup + virtual void showMessagePopup(uint32_t from, uint32_t to, uint8_t ch, const char *name); + // hide new message popup + virtual void hideMessagePopup(void); + // display user widget (dynamically created) + void showUserWidget(UserWidgetFunc createWidget); + // display messages of a group channel + virtual void addChat(uint32_t from, uint32_t to, uint8_t ch); + // mark chat border to indicate a new message + virtual void highlightChat(uint32_t from, uint32_t to, uint8_t ch); + // display number of active chats + virtual void updateActiveChats(void); + // display new message popup + virtual void showMessages(uint8_t channel); + // display messages of a node + virtual void showMessages(uint32_t nodeNum); + // own chat message + virtual void handleAddMessage(char *msg); + // add own message to current chat + virtual void addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t requestId, char *msg, LogMessage::MsgStatus status); + // add new message to container + virtual void newMessage(uint32_t nodeNum, lv_obj_t *container, uint8_t channel, const char *msg); + // create empty message container for node or group channel + virtual lv_obj_t *newMessageContainer(uint32_t from, uint32_t to, uint8_t ch); + // filter or highlight node + virtual bool applyNodesFilter(uint32_t nodeNum, bool reset = false); + // display message alert popup + virtual void messageAlert(const char *alert, bool show); + // mark sent message as received + virtual void handleTextMessageResponse(uint32_t channelOrNode, uint32_t id, bool ack, bool err); + // set node image based on role + virtual void setNodeImage(uint32_t nodeNum, eRole role, bool unmessagable, lv_obj_t *img); + // apply filter and count number of filtered nodes + virtual void updateNodesFiltered(bool reset); + // set last heard to now, update nodes online + virtual void updateLastHeard(uint32_t nodeNum); + // update last heard value on all node panels + virtual void updateAllLastHeard(void); + // update image and unread messages on home screen + virtual void updateUnreadMessages(void); + // update time display on home screen + virtual void updateTime(void); + // update SD card slot info + virtual bool updateSDCard(void); + // format SD card if invalid + virtual void formatSDCard(void); + // update time display on home screen + virtual void updateFreeMem(void); + // update distance to other node + virtual void updateDistance(uint32_t nodeNum, int32_t lat, int32_t lon); + // show map and load tiles + virtual void loadMap(void); + // add objects on map + virtual void addOrUpdateMap(uint32_t nodeNum, int32_t lat, int32_t lon); + // remove objects from map + virtual void removeFromMap(uint32_t nodeNum); + + std::function drawObjectCB; + + NodeFilter filter; + NodeHighlight highlight; private: // view creation only via ViewFactory @@ -33,5 +210,239 @@ class TFTView_480x222 : public MeshtasticView TFTView_480x222(); TFTView_480x222(const DisplayDriverConfig *cfg, DisplayDriver *driver); - static TFTView_480x222 *gui; -}; \ No newline at end of file + void enterProgrammingMode(void); + void updateTheme(void); + void ui_events_init(void); + void ui_set_active(lv_obj_t *b, lv_obj_t *p, lv_obj_t *tp); + void showKeyboard(lv_obj_t *textArea); + void hideKeyboard(lv_obj_t *panel); + lv_obj_t *showQrCode(lv_obj_t *parent, const char *data); + + void enablePanel(lv_obj_t *panel); + void disablePanel(lv_obj_t *panel); + void setGroupFocus(lv_obj_t *panel); + void setInputGroup(void); + void setInputButtonLabel(void); + void updateGroupChannel(uint8_t chId); + + void backup(uint32_t option); + void restore(uint32_t option); + + void scanSignal(uint32_t scanNo); + void handleTraceRouteResponse(const meshtastic_Routing &routing); + void addNodeToTraceRoute(uint32_t nodeNum, lv_obj_t *panel); + void purgeNode(uint32_t nodeNum); + void removeSpinner(void); + void packetDetected(const meshtastic_MeshPacket &p); + void writePacketLog(const meshtastic_MeshPacket &p); + void updateStatistics(const meshtastic_MeshPacket &p); + void updateSignalStrength(int32_t rssi, float snr); + int32_t signalStrength2Percent(int32_t rx_rssi, float rx_snr); + + uint32_t preset2val(meshtastic_Config_LoRaConfig_ModemPreset preset); + meshtastic_Config_LoRaConfig_ModemPreset val2preset(uint32_t val); + uint32_t role2val(meshtastic_Config_DeviceConfig_Role role); + meshtastic_Config_DeviceConfig_Role val2role(uint32_t val); + uint32_t language2val(meshtastic_Language lang); + meshtastic_Language val2language(uint32_t val); + void setLocale(meshtastic_Language lang); + void setLanguage(meshtastic_Language lang); + void setTimeout(uint32_t timeout); + void setBrightness(uint32_t brightness); + void setTheme(uint32_t theme); + void storeNodeOptions(void); + void eraseChat(uint32_t channelOrNode); + void clearChatHistory(void); + void showLoRaFrequency(const meshtastic_Config_LoRaConfig &cfg); + void setBellText(bool banner, bool sound); + void setChannelName(const meshtastic_Channel &ch); + uint32_t timestamp(char *buf, uint32_t time, bool update); + void updateLocationMap(uint32_t objects); + + // response callbacks + void onTextMessageCallback(const ResponseHandler::Request &, ResponseHandler::EventType, int32_t); + void onPositionCallback(const ResponseHandler::Request &, ResponseHandler::EventType, int32_t); + void onTracerouteCallback(const ResponseHandler::Request &, ResponseHandler::EventType, int32_t); + + // lvgl timer callbacks + static void timer_event_reboot(lv_timer_t *timer); + static void timer_event_shutdown(lv_timer_t *timer); + static void timer_event_programming_mode(lv_timer_t *timer); + + // lvgl event callbacks + static void ui_event_LogoButton(lv_event_t *e); + static void ui_event_BluetoothButton(lv_event_t *e); + + // static void ui_event_HomeButton(lv_event_t * e); + static void ui_event_MainButtonFocus(lv_event_t *e); + static void ui_event_GlobalKeyHandler(lv_event_t *e); + static void ui_event_NodesButton(lv_event_t *e); + static void ui_event_GroupsButton(lv_event_t *e); + static void ui_event_MessagesButton(lv_event_t *e); + static void ui_event_MapButton(lv_event_t *e); + static void ui_event_SettingsButton(lv_event_t *e); + + static void ui_event_NodeButton(lv_event_t *e); + static void ui_event_ChannelButton(lv_event_t *e); + static void ui_event_ChatButton(lv_event_t *e); + static void ui_event_ChatDelButton(lv_event_t *e); + static void ui_event_MsgPopupButton(lv_event_t *e); + static void ui_event_MsgRestoreButton(lv_event_t *e); + static void ui_event_AlertButton(lv_event_t *e); + + // Home screen + static void ui_event_EnvelopeButton(lv_event_t *e); + static void ui_event_OnlineNodesButton(lv_event_t *e); + static void ui_event_TimeButton(lv_event_t *e); + static void ui_event_LoRaButton(lv_event_t *e); + static void ui_event_BellButton(lv_event_t *e); + static void ui_event_LocationButton(lv_event_t *e); + static void ui_event_WLANButton(lv_event_t *e); + static void ui_event_MQTTButton(lv_event_t *e); + static void ui_event_SDCardButton(lv_event_t *e); + static void ui_event_MemoryButton(lv_event_t *e); + static void ui_event_QrButton(lv_event_t *e); + static void ui_event_CancelQrButton(lv_event_t *e); + + // blank screen + static void ui_event_BlankScreenButton(lv_event_t *e); + + static void ui_event_KeyboardButton(lv_event_t *e); + static void ui_event_Keyboard(lv_event_t *e); + + static void ui_event_message_ready(lv_event_t *e); + + static void ui_event_user_button(lv_event_t *e); + static void ui_event_role_button(lv_event_t *e); + static void ui_event_region_button(lv_event_t *e); + static void ui_event_preset_button(lv_event_t *e); + static void ui_event_wifi_button(lv_event_t *e); + static void ui_event_language_button(lv_event_t *e); + static void ui_event_channel_button(lv_event_t *e); + static void ui_event_brightness_button(lv_event_t *e); + static void ui_event_theme_button(lv_event_t *e); + static void ui_event_calibration_button(lv_event_t *e); + static void ui_event_timeout_button(lv_event_t *e); + static void ui_event_screen_lock_button(lv_event_t *e); + static void ui_event_input_button(lv_event_t *e); + static void ui_event_alert_button(lv_event_t *e); + static void ui_event_backup_button(lv_event_t *e); + static void ui_event_reset_button(lv_event_t *e); + static void ui_event_reboot_button(lv_event_t *e); + static void ui_event_device_reboot_button(lv_event_t *e); + static void ui_event_device_progmode_button(lv_event_t *e); + static void ui_event_device_shutdown_button(lv_event_t *e); + static void ui_event_device_cancel_button(lv_event_t *e); + static void ui_event_shutdown_button(lv_event_t *e); + static void ui_event_modify_channel(lv_event_t *e); + static void ui_event_delete_channel(lv_event_t *e); + static void ui_event_generate_psk(lv_event_t *e); + static void ui_event_qr_code(lv_event_t *e); + + static void ui_event_screen_timeout_slider(lv_event_t *e); + static void ui_event_brightness_slider(lv_event_t *e); + static void ui_event_frequency_slot_slider(lv_event_t *e); + static void ui_event_modem_preset_dropdown(lv_event_t *e); + static void ui_event_setup_region_dropdown(lv_event_t *e); + static void ui_event_map_style_dropdown(lv_event_t *e); + + static void ui_event_calibration_screen_loaded(lv_event_t *e); + + static void ui_event_mesh_detector(lv_event_t *e); + static void ui_event_mesh_detector_start(lv_event_t *e); + static void ui_event_signal_scanner(lv_event_t *e); + static void ui_event_signal_scanner_node(lv_event_t *e); + static void ui_event_signal_scanner_start(lv_event_t *e); + static void ui_event_trace_route(lv_event_t *e); + static void ui_event_trace_route_to(lv_event_t *e); + static void ui_event_trace_route_start(lv_event_t *e); + static void ui_event_trace_route_node(lv_event_t *e); + static void ui_event_node_details(lv_event_t *e); + static void ui_event_statistics(lv_event_t *e); + static void ui_event_packet_log(lv_event_t *e); + + static void ui_event_pin_screen_button(lv_event_t *e); + static void ui_event_statistics_table(lv_event_t *e); + + static void ui_event_ok(lv_event_t *e); + static void ui_event_cancel(lv_event_t *e); + static void ui_event_backup_restore_radio_button(lv_event_t *e); + + // map navigation + static void ui_screen_event_cb(lv_event_t *e); + static void ui_event_arrow(lv_event_t *e); + static void ui_event_navHome(lv_event_t *e); + static void ui_event_zoomSlider(lv_event_t *e); + static void ui_event_zoomIn(lv_event_t *e); + static void ui_event_zoomOut(lv_event_t *e); + static void ui_event_lockGps(lv_event_t *e); + static void ui_event_mapBrightnessSlider(lv_event_t *e); + static void ui_event_mapContrastSlider(lv_event_t *e); + static void ui_event_mapNodeButton(lv_event_t *e); + static void ui_event_chatNodeButton(lv_event_t *e); + static void ui_event_positionButton(lv_event_t *e); + + // animations + static void ui_anim_node_panel_cb(void *var, int32_t v); + static void ui_anim_radar_cb(void *var, int32_t r); + + lv_obj_t *activeButton = nullptr; + lv_obj_t *activePanel = nullptr; + lv_obj_t *activeTopPanel = nullptr; + lv_obj_t *activeMsgContainer = nullptr; + lv_obj_t *activeWidget = nullptr; + lv_obj_t *activeTextInput = nullptr; + lv_group_t *input_group = nullptr; + + enum BasicSettings activeSettings = eNone; // active settings menu (used to disable other button presses) + + static TFTView_480x222 *gui; // singleton pattern + bool screensInitialised; // true if init_screens is completed + uint32_t nodesFiltered; // no. hidden nodes in node list + bool nodesChanged; // true if nodes changed (added or purged) + bool processingFilter; // indicates that filtering is ongoing + bool packetLogEnabled; // display received packets + bool detectorRunning; // meshDetector is active + bool cardDetected; // SD has been detected + bool formatSD; // offer to format SD card + uint16_t buttonSize; // size of group/chat buttons in pixels + uint16_t statisticTableRows; // number of rows in statistics table + uint16_t packetCounter; // number of packets in packet log + time_t lastrun60, lastrun10, lastrun5, lastrun1; // timers for task loop + time_t actTime, uptime, lastHeard; // actual time and uptime; time last heard a node + bool hasPosition; // if our position is known + int32_t myLatitude, myLongitude; // our current position as reported by firmware + void *topNodeLL; // pointer to topmost button in group ll + uint32_t scans; // scanner counter + lv_anim_t radar; // radar animation + static uint32_t currentNode; // current selected node + static lv_obj_t *currentPanel; // current selected node panel + static lv_obj_t *spinnerButton; // start button animation + static time_t startTime; // time when start button was pressed + static uint32_t pinKeys; // number of keys pressed (lock screen) + static bool screenLocked; // screen lock active + static bool screenUnlockRequest; // screen unlock request (via button) + uint32_t selectedHops; // remember selected choice + bool chooseNodeSignalScanner; // chose a target node for signal scanner + bool chooseNodeTraceRoute; // chose a target node for trace route + char old_val1_scratch[64], old_val2_scratch[64]; // temporary scratch buffers for settings strings + std::array ch_label; // indexable label list for settings + meshtastic_Channel *channel_scratch; // temporary scratch copy of channel db + lv_obj_t *qr; // qr code + MapPanel *map = nullptr; // map + std::unordered_map nodeObjects; // nodeObjects displayed on map + // extended default device profile struct with additional required data + struct meshtastic_DeviceProfile_ext : meshtastic_DeviceProfile { + meshtastic_User user; + meshtastic_Channel channel[c_max_channels]; // storage of channel info + meshtastic_DeviceUIConfig uiConfig; // storage of persistent UI data + }; + + // additional local ui data (non-persistent) + struct meshtastic_DeviceProfile_full : meshtastic_DeviceProfile_ext { + bool silent; // sound silenced + meshtastic_DeviceConnectionStatus connectionStatus; // wifi/bluetooth/ethernet + }; + + meshtastic_DeviceProfile_full db{}; // full copy of the node's configuration db (except nodeinfos) plus ui data +}; diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 3c6c9157..8f8343f7 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -5737,10 +5737,16 @@ bool TFTView_320x240::applyNodesFilter(uint32_t nodeNum, bool reset) void TFTView_320x240::messageAlert(const char *alert, bool show) { lv_label_set_text(objects.alert_label, alert); - if (show) + if (show) { lv_obj_clear_flag(objects.alert_panel, LV_OBJ_FLAG_HIDDEN); - else + // Auto-hide after 3 seconds + lv_timer_create([](lv_timer_t *timer) { + lv_obj_add_flag(objects.alert_panel, LV_OBJ_FLAG_HIDDEN); + lv_timer_delete(timer); + }, 3000, NULL); + } else { lv_obj_add_flag(objects.alert_panel, LV_OBJ_FLAG_HIDDEN); + } } /** diff --git a/source/graphics/TFT/TFTView_480x222.cpp b/source/graphics/TFT/TFTView_480x222.cpp index dba862bb..0ab7ea70 100644 --- a/source/graphics/TFT/TFTView_480x222.cpp +++ b/source/graphics/TFT/TFTView_480x222.cpp @@ -1,45 +1,7476 @@ #if HAS_TFT && defined(VIEW_480x222) #include "graphics/view/TFT/TFTView_480x222.h" +#include "Arduino.h" +#include "graphics/common/BatteryLevel.h" +#include "graphics/common/LoRaPresets.h" +#include "graphics/common/Ringtones.h" #include "graphics/common/ViewController.h" +#include "graphics/driver/DisplayDriver.h" #include "graphics/driver/DisplayDriverFactory.h" -#include "ui.h" // this is the ui generated by eez-studio / lvgl +#include "graphics/map/MapPanel.h" +#include "graphics/view/TFT/Themes.h" +#include "images.h" +#include "input/InputDriver.h" +#include "input/I2CKeyboardInputDriver.h" +#include "lv_i18n.h" +#include "lvgl_private.h" +#include "styles.h" +#include "ui.h" +#include "util/FileLoader.h" #include "util/ILog.h" +#include +#include #include -#include #include +#include +#include +#include +#include +#include +#include + +#if defined(ARCH_PORTDUINO) +#include "PortduinoFS.h" +fs::FS &fileSystem = PortduinoFS; +#else +#include "LittleFS.h" +fs::FS &fileSystem = LittleFS; +#endif + +#if defined(ARCH_PORTDUINO) +#include "util/LinuxHelper.h" +// #include "graphics/map/LinuxFileSystemService.h" +#include "graphics/map/SDCardService.h" +#elif defined(HAS_SD_MMC) +#include "graphics/map/SDCardService.h" +#else +#include "graphics/map/SdFatService.h" +#endif +#include "graphics/common/SdCard.h" + +#ifndef MAX_NUM_NODES_VIEW +#define MAX_NUM_NODES_VIEW 250 +#endif + +#ifndef PACKET_LOGS_MAX +#define PACKET_LOGS_MAX 200 +#endif + +LV_IMAGE_DECLARE(img_circle_image); +LV_IMAGE_DECLARE(img_no_tile_image); +LV_IMAGE_DECLARE(node_location_pin24_image); + +#define CR_REPLACEMENT 0x0C // dummy to record several lines in a one line textarea +#define THIS TFTView_480x222::instance() // need to use this in all static methods + +#define LV_COLOR_HEX(C) \ + { \ + .blue = (C >> 0) & 0xff, .green = (C >> 8) & 0xff, .red = (C >> 16) & 0xff \ + } + +#define VALID_TIME(T) (T > 1000000 && T < UINT32_MAX) + +constexpr lv_color_t colorRed = LV_COLOR_HEX(0xff5555); +constexpr lv_color_t colorDarkRed = LV_COLOR_HEX(0xa70a0a); +constexpr lv_color_t colorOrange = LV_COLOR_HEX(0xff8c04); +constexpr lv_color_t colorYellow = LV_COLOR_HEX(0xdbd251); +constexpr lv_color_t colorBlueGreen = LV_COLOR_HEX(0x05f6cb); +constexpr lv_color_t colorBlue = LV_COLOR_HEX(0x436C70); +constexpr lv_color_t colorGray = LV_COLOR_HEX(0x757575); +constexpr lv_color_t colorLightGray = LV_COLOR_HEX(0xAAFBFF); +constexpr lv_color_t colorMidGray = LV_COLOR_HEX(0x808080); +constexpr lv_color_t colorDarkGray = LV_COLOR_HEX(0x303030); +constexpr lv_color_t colorMesh = LV_COLOR_HEX(0x67ea94); + +// children index of nodepanel lv objects (see addNode) +enum NodePanelIdx { + node_img_idx, + node_btn_idx, + node_lbl_idx, + node_lbs_idx, + node_bat_idx, + node_lh_idx, + node_sig_idx, + node_pos1_idx, + node_pos2_idx, + node_tm1_idx, + node_tm2_idx +}; + +enum ScrollDirection { + scrollDownLeft = 1, + scrollDown = 2, + scrollDownRight = 3, + scrollLeft = 4, + scrollRight = 6, + scrollUpLeft = 7, + scrollUp = 8, + scrollUpRight = 9, +}; + +extern const char *firmware_version; TFTView_480x222 *TFTView_480x222::gui = nullptr; +lv_obj_t *TFTView_480x222::currentPanel = nullptr; +lv_obj_t *TFTView_480x222::spinnerButton = nullptr; +uint32_t TFTView_480x222::currentNode = 0; +time_t TFTView_480x222::startTime = 0; +uint32_t TFTView_480x222::pinKeys = 0; +bool TFTView_480x222::screenLocked = false; +bool TFTView_480x222::screenUnlockRequest = false; TFTView_480x222 *TFTView_480x222::instance(void) { - if (!gui) + if (!gui) { gui = new TFTView_480x222(nullptr, DisplayDriverFactory::create(480, 222)); + } return gui; } TFTView_480x222 *TFTView_480x222::instance(const DisplayDriverConfig &cfg) { - if (!gui) + if (!gui) { gui = new TFTView_480x222(&cfg, DisplayDriverFactory::create(cfg)); + } return gui; } TFTView_480x222::TFTView_480x222(const DisplayDriverConfig *cfg, DisplayDriver *driver) - : MeshtasticView(cfg, driver, new ViewController) + : MeshtasticView(cfg, driver, new ViewController), screensInitialised(false), nodesFiltered(0), nodesChanged(true), + processingFilter(false), packetLogEnabled(false), detectorRunning(false), cardDetected(false), formatSD(false), + packetCounter(0), actTime(0), uptime(0), lastHeard(0), hasPosition(false), myLatitude(0), myLongitude(0), + topNodeLL(nullptr), scans(0), selectedHops(0), chooseNodeSignalScanner(false), chooseNodeTraceRoute(false), qr(nullptr), + db{} { + filter.active = false; + highlight.active = false; + objects.main_screen = nullptr; } +/** + * @brief Initialize view and boot screen + * Note: We'll wait until we got our persistent data and then initialize + * the remaining screens. + */ void TFTView_480x222::init(IClientBase *client) { ILOG_DEBUG("TFTView_480x222 init..."); + ILOG_DEBUG("TFTView_480x222 db size: %d", sizeof(TFTView_480x222)); + ILOG_DEBUG("### Images size in flash ###"); + uint32_t total_size = 0; + for (int i = 0; i < sizeof(images) / sizeof(ext_img_desc_t); i++) { + total_size += images[i].img_dsc->data_size; + ILOG_DEBUG(" %s: %d", images[i].name, images[i].img_dsc->data_size); + } + ILOG_DEBUG("================================"); + ILOG_DEBUG("### Total size: %d bytes ###", total_size); + MeshtasticView::init(client); + + ui_init_boot(); + FileLoader::init(&fileSystem); + FileLoader::loadBootImage(objects.boot_logo); + // if boot logo is too big remove the label and center the image + lv_obj_update_layout(objects.boot_logo); + if (lv_obj_get_height(objects.boot_logo) > lv_display_get_vertical_resolution(displaydriver->getDisplay()) / 2) { + lv_obj_set_pos(objects.boot_logo, 0, 0); + lv_obj_add_flag(objects.firmware_label, LV_OBJ_FLAG_HIDDEN); + } else { + lv_label_set_text(objects.firmware_label, firmware_version); + } + + time(&lastrun60); + time(&lastrun10); + time(&lastrun5); + time(&lastrun1); + + lv_obj_add_event_cb(objects.boot_logo_button, ui_event_LogoButton, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.blank_screen_button, ui_event_BlankScreenButton, LV_EVENT_ALL, NULL); + + lv_timer_create(timer_event_programming_mode, 3000, NULL); // timer for programming mode button active +} + +/** + * @brief initialize UI with persistent data + */ +bool TFTView_480x222::setupUIConfig(const meshtastic_DeviceUIConfig &uiconfig) +{ + if (uiconfig.version == 1) { + ILOG_INFO("setupUIConfig version %d", uiconfig.version); + db.uiConfig = uiconfig; + if (db.uiConfig.screen_timeout == 1) { + db.uiConfig.screen_timeout = 30; + controller->storeUIConfig(db.uiConfig); + } + } else { + ILOG_WARN("invalid uiconfig version %d, reset UI settings to default", uiconfig.version); + db.uiConfig.version = 1; + db.uiConfig.screen_brightness = 153; + db.uiConfig.screen_timeout = 30; + controller->storeUIConfig(db.uiConfig); + } + + lv_i18n_init(lv_i18n_language_pack); + setLocale(db.uiConfig.language); + + if (state == MeshtasticView::eEnterProgrammingMode || state == MeshtasticView::eProgrammingMode || + state == MeshtasticView::eWaitingForReboot) { + enterProgrammingMode(); + return false; + } + + state = MeshtasticView::eSetupUIConfig; + + // now we have set language, continue creating all screens + if (!screensInitialised) + init_screens(); + + // set language + setLanguage(db.uiConfig.language); + + // TODO: set virtual keyboard according language + // setKeyboard(db.uiConfig.language); + + // set theme + setTheme(db.uiConfig.theme); + + // grey out bell until we got the ringtone (0 = silent) + Themes::recolorButton(objects.home_bell_button, false); + Themes::recolorText(objects.home_bell_label, false); + + lv_obj_set_style_bg_img_recolor(objects.home_button, colorMesh, LV_PART_MAIN | LV_STATE_DEFAULT); + + // set brightness + if (displaydriver->hasLight()) + THIS->setBrightness(db.uiConfig.screen_brightness); + + // set timeout + THIS->setTimeout(db.uiConfig.screen_timeout); + + // set screen/settings lock + char buf[40]; + lv_snprintf(buf, 40, _("Lock: %s/%s"), db.uiConfig.screen_lock ? _("on") : _("off"), + db.uiConfig.settings_lock ? _("on") : _("off")); + lv_label_set_text(objects.basic_settings_screen_lock_label, buf); + + // set node filter options + meshtastic_NodeFilter &filter = db.uiConfig.node_filter; + lv_obj_set_state(objects.nodes_filter_unknown_switch, LV_STATE_CHECKED, filter.unknown_switch); + lv_obj_set_state(objects.nodes_filter_offline_switch, LV_STATE_CHECKED, filter.offline_switch); + lv_obj_set_state(objects.nodes_filter_public_key_switch, LV_STATE_CHECKED, filter.public_key_switch); + // lv_dropdown_set_selected(objects.nodes_filter_channel_dropdown, filter.channel); + lv_dropdown_set_selected(objects.nodes_filter_hops_dropdown, filter.hops_away); + // lv_obj_set_state(objects.nodes_filter_mqtt_switch, LV_STATE_CHECKED, filter.mqtt_switch); + lv_obj_set_state(objects.nodes_filter_position_switch, LV_STATE_CHECKED, filter.position_switch); + lv_textarea_set_text(objects.nodes_filter_name_area, filter.node_name); + + // set node highlight options + meshtastic_NodeHighlight &highlight = db.uiConfig.node_highlight; + lv_obj_set_state(objects.nodes_hl_active_chat_switch, LV_STATE_CHECKED, highlight.chat_switch); + lv_obj_set_state(objects.nodes_hl_position_switch, LV_STATE_CHECKED, highlight.position_switch); + lv_obj_set_state(objects.nodes_hl_telemetry_switch, LV_STATE_CHECKED, highlight.telemetry_switch); + lv_obj_set_state(objects.nodes_hliaq_switch, LV_STATE_CHECKED, highlight.iaq_switch); + lv_textarea_set_text(objects.nodes_hl_name_area, highlight.node_name); + + // initialize own node panel + if (ownNode && objects.node_panel) + nodes[ownNode] = objects.node_panel; + + // touch screen calibration data + uint16_t *parameters = (uint16_t *)db.uiConfig.calibration_data.bytes; + if (db.uiConfig.calibration_data.size == 16 && (parameters[0] || parameters[7])) { +#ifndef IGNORE_CALIBRATION_DATA + bool done = displaydriver->calibrate(parameters); + char buf[32]; + lv_snprintf(buf, sizeof(buf), _("Screen Calibration: %s"), done ? _("done") : _("default")); + lv_label_set_text(objects.basic_settings_calibration_label, buf); +#endif + } + + // update home panel bell text + setBellText(db.uiConfig.alert_enabled, !db.silent); + bool off = !db.uiConfig.alert_enabled && db.silent; + Themes::recolorButton(objects.home_bell_button, !off); + Themes::recolorText(objects.home_bell_label, !off); + objects.home_bell_button->user_data = (void *)off; + + // check SD card + updateSDCard(); + + // function callback for the map panel node symbol + drawObjectCB = [this](uint32_t id, uint16_t x, uint16_t y, uint8_t zoom) { + auto img = nodeObjects[id]; + if (!x && !y && !zoom) { + lv_obj_add_flag(img, LV_OBJ_FLAG_HIDDEN); + return; + } + lv_obj_move_foreground(img); + lv_obj_clear_flag(img, LV_OBJ_FLAG_HIDDEN); + if (zoom >= 10 || (zoom >= 7 && nodeObjects.size() < 10)) { + lv_obj_clear_flag(img->spec_attr->children[0], LV_OBJ_FLAG_HIDDEN); + } else { + // hide text + lv_obj_add_flag(img->spec_attr->children[0], LV_OBJ_FLAG_HIDDEN); + } + if (zoom >= 4) { + // pin location image + lv_img_set_src(img, &img_node_location_pin24_image); + lv_img_set_zoom(img, 256); + lv_obj_set_pos(img, x - 20, y - 24); // img has 40x35 size, needle at 24 + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_TOP_MID); + // lv_obj_set_style_align(img->spec_attr->children[0], LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); + } else { + // circle image + lv_img_set_src(img, &img_circle_image); + lv_img_set_zoom(img, (zoom - 1) * 50 + 80); + lv_obj_set_pos(img, x - 20, y - 17); // img has 40x35 size, circle at center + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CENTER); + // lv_obj_set_style_align(img->spec_attr->children[0], LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + } + }; + + lv_disp_trig_activity(NULL); + return true; +} + +/** + * @brief display custom message on boot screen + * Note: currently, the firmware version field is used and set in main()/setup() + */ +void TFTView_480x222::updateBootMessage(const char *msg) +{ + if (msg) + lv_label_set_text(objects.firmware_label, msg); +} + +/** + * @brief Initialize all screens and apply customizations + * + */ +void TFTView_480x222::init_screens(void) +{ + ILOG_DEBUG("init screens..."); + state = MeshtasticView::eInitScreens; ui_init(); + apply_hotfix(); + + activeMsgContainer = objects.messages_container; + // setup the two channel label panels with arrays that allow indexing + channel = {objects.channel_label0, objects.channel_label1, objects.channel_label2, objects.channel_label3, + objects.channel_label4, objects.channel_label5, objects.channel_label6, objects.channel_label7}; + ch_label = {objects.settings_channel0_label, objects.settings_channel1_label, objects.settings_channel2_label, + objects.settings_channel3_label, objects.settings_channel4_label, objects.settings_channel5_label, + objects.settings_channel6_label, objects.settings_channel7_label}; + + channelGroup = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; + ui_set_active(objects.home_button, objects.home_panel, objects.top_panel); + ui_events_init(); + + // load main screen + lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_NONE, 300, 0, false); + + // re-configuration based on capabilities + if (!displaydriver->hasLight()) + lv_obj_add_flag(objects.basic_settings_brightness_button, LV_OBJ_FLAG_HIDDEN); + +#ifndef CUSTOM_TOUCH_DRIVER + if (!displaydriver->hasTouch()) +#endif + lv_obj_add_flag(objects.basic_settings_calibration_button, LV_OBJ_FLAG_HIDDEN); + +#if LV_USE_LIBINPUT + lv_obj_clear_flag(objects.basic_settings_input_button, LV_OBJ_FLAG_HIDDEN); +#endif + +#if defined(USE_I2S_BUZZER) || defined(USE_PIN_BUZZER) + lv_obj_clear_flag(objects.basic_settings_alert_button, LV_OBJ_FLAG_HIDDEN); + db.uiConfig.ring_tone_id = 0; +#else + lv_obj_add_flag(objects.basic_settings_alert_button, LV_OBJ_FLAG_HIDDEN); +#endif + +#ifndef USE_ROUTER_ROLE + lv_dropdown_set_options(objects.settings_device_role_dropdown, + _("Client\nClient Mute\nTracker\nSensor\nTAK\nClient Hidden\nLost & Found\nTAK Tracker")); +#endif + +#ifdef HAS_SDCARD + lv_obj_clear_flag(objects.basic_settings_backup_restore_button, LV_OBJ_FLAG_HIDDEN); +#endif + + if (controller->isStandalone()) { + lv_obj_add_flag(objects.progmode_button, LV_OBJ_FLAG_HIDDEN); + } + + // signal scanner scale +#if defined(USE_SX127x) + lv_label_set_text(objects.signal_scanner_rssi_scale_label, "-50\n-60\n-70\n-80\n-90\n-100\n-110\n-120\n-130\n-140\n-150"); + lv_slider_set_range(objects.rssi_slider, -150, -50); + lv_label_set_text(objects.signal_scanner_snr_scale_label, + "14.0\n12.0\n10.0\n8.0\n6.0\n4.0\n2.0\n0.0\n-2.0\n-4.0\n-8.0\n-10.0\n-12.0\n-14.0\n-16.0"); + lv_obj_set_style_text_line_space(objects.signal_scanner_snr_scale_label, -2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_slider_set_range(objects.snr_slider, -17, 15); +#else + lv_label_set_text(objects.signal_scanner_rssi_scale_label, "-20\n-30\n-40\n-50\n-60\n-70\n-80\n-90\n-100\n-110\n-120"); + lv_slider_set_range(objects.rssi_slider, -125, -25); + lv_label_set_text(objects.signal_scanner_snr_scale_label, + "8.0\n6.0\n4.0\n2.0\n0.0\n-2.0\n-4.0\n-8.0\n-10.0\n-12.0\n-14.0\n-16.0\n-18.0"); + lv_obj_set_style_text_line_space(objects.signal_scanner_snr_scale_label, -2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_slider_set_range(objects.snr_slider, -20, 9); +#endif + + setInputButtonLabel(); + lv_group_focus_obj(objects.home_button); + + // remember position of top node panel button for group linked list + lv_ll_t *lv_group_ll = &lv_group_get_default()->obj_ll; + for (lv_obj_t **obj_i = (lv_obj_t **)_lv_ll_get_head(lv_group_ll); obj_i != NULL; + obj_i = (lv_obj_t **)_lv_ll_get_next(lv_group_ll, obj_i)) { + if (*obj_i == objects.node_button) { + topNodeLL = obj_i; + break; + } + } + + // user data + objects.home_time_button->user_data = (void *)0; + objects.home_wlan_button->user_data = (void *)0; + objects.home_memory_button->user_data = (void *)0; + + updateFreeMem(); + + screensInitialised = true; + state = MeshtasticView::eInitDone; + ILOG_DEBUG("TFTView_480x222 init done."); } -void TFTView_480x222::task_handler(void) +/** + * @brief set active button, panel and top panel + * + * @param b button to set active + * @param p main panel to set active + * @param tp top panel to set active + */ +void TFTView_480x222::ui_set_active(lv_obj_t *b, lv_obj_t *p, lv_obj_t *tp) { - MeshtasticView::task_handler(); + if (activeButton) { + // Reset previous button styling + lv_obj_set_style_border_width(activeButton, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(activeButton, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); + if (Themes::get() == Themes::eDark) + lv_obj_set_style_bg_img_recolor_opa(activeButton, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor(activeButton, colorGray, LV_PART_MAIN | LV_STATE_DEFAULT); + } + // Highlight new active button with green background and dark icon + lv_obj_set_style_border_width(b, 3, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(b, colorMesh, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor(b, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(b, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + + if (activePanel) { + lv_obj_add_flag(activePanel, LV_OBJ_FLAG_HIDDEN); + if (activePanel == objects.messages_panel) { + lv_obj_remove_state(objects.message_input_area, LV_STATE_FOCUSED); + if (!lv_obj_has_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN)) { + hideKeyboard(objects.messages_panel); + } + uint32_t channelOrNode = (unsigned long)activeMsgContainer->user_data; + // remove empty messageContainer if we are leaving messages panel + if (channelOrNode >= c_max_channels) { + if (activeMsgContainer->spec_attr->child_cnt == 0) { + eraseChat(channelOrNode); + updateActiveChats(); + activeMsgContainer = objects.messages_container; + } + } + unreadMessages = 0; // TODO: not all messages may be actually read + updateUnreadMessages(); + } else if (activePanel == objects.node_options_panel) { + // we're moving away from node options panel, so save latest settings + storeNodeOptions(); + } + } + + lv_obj_clear_flag(p, LV_OBJ_FLAG_HIDDEN); + + if (tp) { + if (activeTopPanel) { + lv_obj_add_flag(activeTopPanel, LV_OBJ_FLAG_HIDDEN); + } + lv_obj_clear_flag(tp, LV_OBJ_FLAG_HIDDEN); + activeTopPanel = tp; + } + + activeButton = b; + activePanel = p; + if (activePanel == objects.messages_panel) { + // Always focus input area - KEY handler in ui_event_message_ready scrolls when empty + lv_group_focus_obj(objects.message_input_area); + } else if (inputdriver->hasKeyboardDevice() || inputdriver->hasEncoderDevice()) { + setGroupFocus(activePanel); + } + + lv_obj_add_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.msg_popup_panel, LV_OBJ_FLAG_HIDDEN); +} + +void TFTView_480x222::enterProgrammingMode(void) +{ + if (state == eEnterProgrammingMode && !db.config.bluetooth.enabled) { + ILOG_INFO("rebooting into programming mode"); + lv_label_set_text(objects.meshtastic_url, _("Rebooting ...")); + + if (ownNode) { + meshtastic_Config_NetworkConfig &network = THIS->db.config.network; + if (network.wifi_enabled) { + network.wifi_enabled = false; + THIS->controller->sendConfig(meshtastic_Config_NetworkConfig{network}); + } + meshtastic_Config_BluetoothConfig &bluetooth = THIS->db.config.bluetooth; + bluetooth.mode = meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN; + bluetooth.fixed_pin = random(100000, 999999); + bluetooth.enabled = true; + THIS->controller->sendConfig(meshtastic_Config_BluetoothConfig{bluetooth}, ownNode); + state = MeshtasticView::eWaitingForReboot; + } + } else { + state = MeshtasticView::eProgrammingMode; + lv_label_set_text(objects.meshtastic_url, _(">> Programming mode <<")); + lv_label_set_text_fmt(objects.firmware_label, "%06d", db.config.bluetooth.fixed_pin); + lv_obj_set_style_text_font(objects.firmware_label, &ui_font_montserrat_20, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(objects.boot_logo, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.boot_logo_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(objects.bluetooth_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_event_cb(objects.bluetooth_button, ui_event_BluetoothButton, LV_EVENT_LONG_PRESSED, NULL); + ILOG_INFO("### MUI programming mode entered (nodeId=!%08x) ###", ownNode); + } +} + +/** + * @brief fix quirks in the generated ui + * + */ +void TFTView_480x222::apply_hotfix(void) +{ + // adapt screens to custom display resolution + uint32_t h = lv_display_get_horizontal_resolution(displaydriver->getDisplay()); + uint32_t v = lv_display_get_vertical_resolution(displaydriver->getDisplay()); + + // resize buttons on larger display (assuming 480x480) + if (h > 320 && v > 320) { + lv_obj_t *button[] = {objects.home_button, objects.nodes_button, objects.groups_button, + objects.messages_button, objects.map_button, objects.settings_button}; + for (int i = 0; i < 6; i++) { + lv_obj_set_size(button[i], 72, 72); + } + } + + // fix size for 480 pixel height displays + if (v >= 480) { + // keyboard size limit + lv_obj_set_size(objects.keyboard, LV_PCT(100), LV_PCT(45)); + + // resize channel buttons + buttonSize = 40; + lv_obj_set_height(objects.channel_button0, buttonSize); + lv_obj_set_height(objects.channel_button1, buttonSize); + lv_obj_set_height(objects.channel_button2, buttonSize); + lv_obj_set_height(objects.channel_button3, buttonSize); + lv_obj_set_height(objects.channel_button4, buttonSize); + lv_obj_set_height(objects.channel_button5, buttonSize); + lv_obj_set_height(objects.channel_button6, buttonSize); + lv_obj_set_height(objects.channel_button7, buttonSize); + + lv_obj_set_height(objects.chats_button, buttonSize); + } else { + // chat button size + buttonSize = 36; + } + if (h > 400) { + lv_obj_set_style_text_font(objects.home_qr_label, &ui_font_montserrat_16, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + lv_obj_move_foreground(objects.keyboard); + lv_obj_add_flag(objects.detector_radar_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.detected_node_button, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(objects.detector_start_label, _("Start")); + lv_obj_clear_flag(objects.detector_start_button_panel, LV_OBJ_FLAG_HIDDEN); + + lv_textarea_set_placeholder_text(objects.message_input_area, _("Enter Text ...")); + lv_textarea_set_placeholder_text(objects.nodes_filter_name_area, _("!Enter Filter ...")); + lv_textarea_set_placeholder_text(objects.nodes_hl_name_area, _("Enter Filter ...")); + + auto applyStyle = [](lv_obj_t *tab_buttons) { + for (int i = 0; i < lv_obj_get_child_count(tab_buttons); i++) { + if (tab_buttons->spec_attr->children[i]->class_p == &lv_button_class) { + lv_obj_add_style(tab_buttons->spec_attr->children[i], &style_btn_default, LV_STATE_DEFAULT); + lv_obj_add_style(tab_buttons->spec_attr->children[i], &style_btn_active, LV_STATE_CHECKED); + lv_obj_add_style(tab_buttons->spec_attr->children[i], &style_btn_pressed, LV_STATE_PRESSED); + } + } + }; + + lv_obj_t *tab_buttons = lv_tabview_get_tab_bar(objects.node_options_tab_view); + applyStyle(tab_buttons); + tab_buttons = lv_tabview_get_tab_bar(objects.controller_tab_view); + applyStyle(tab_buttons); + tab_buttons = lv_tabview_get_tab_bar(ui_SettingsTabView); + applyStyle(tab_buttons); + + // add event callback to to apply custom drawing for statistics table + lv_obj_add_event_cb(objects.statistics_table, ui_event_statistics_table, LV_EVENT_DRAW_TASK_ADDED, NULL); + lv_obj_add_flag(objects.statistics_table, LV_OBJ_FLAG_SEND_DRAW_TASK_EVENTS); + // statistics table item size + int32_t width = 36; + int32_t rows = 12; + if (h > 320) { + width = (h - 48 - 57) / 6; + } + if (v > 240) { + rows = (v - 32) / 18; + } + statisticTableRows = rows; + lv_table_set_row_count(objects.statistics_table, statisticTableRows); + lv_table_set_column_count(objects.statistics_table, 7); + lv_table_set_column_width(objects.statistics_table, 0, 57); + lv_table_set_column_width(objects.statistics_table, 1, width); + lv_table_set_column_width(objects.statistics_table, 2, width); + lv_table_set_column_width(objects.statistics_table, 3, width); + lv_table_set_column_width(objects.statistics_table, 4, width); + lv_table_set_column_width(objects.statistics_table, 5, width); + lv_table_set_column_width(objects.statistics_table, 6, width); + // fill table heading + lv_table_set_cell_value(objects.statistics_table, 0, 0, _("Name")); + lv_table_set_cell_value(objects.statistics_table, 0, 1, "Tel"); + lv_table_set_cell_value(objects.statistics_table, 0, 2, "Pos"); + lv_table_set_cell_value(objects.statistics_table, 0, 3, "Inf"); + lv_table_set_cell_value(objects.statistics_table, 0, 4, "Trc"); + lv_table_set_cell_value(objects.statistics_table, 0, 5, "Nbr"); + lv_table_set_cell_value(objects.statistics_table, 0, 6, "All"); + + // transform checkbox into radio button + static lv_style_t style_radio; + lv_style_init(&style_radio); + lv_style_set_radius(&style_radio, LV_RADIUS_CIRCLE); + + lv_obj_add_style(objects.settings_backup_checkbox, &style_radio, LV_PART_INDICATOR); + lv_obj_add_style(objects.settings_restore_checkbox, &style_radio, LV_PART_INDICATOR); +} + +void TFTView_480x222::updateTheme(void) +{ + Themes::initStyles(); + Themes::recolorButton(objects.home_lora_button, db.config.lora.tx_enabled); + Themes::recolorButton(objects.home_bell_button, db.uiConfig.alert_enabled || !db.silent); + Themes::recolorButton(objects.home_location_button, + db.config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED); + Themes::recolorButton(objects.home_wlan_button, db.config.network.wifi_enabled); + Themes::recolorButton(objects.home_mqtt_button, db.module_config.mqtt.enabled); + Themes::recolorButton(objects.home_sd_card_button, cardDetected); + Themes::recolorButton(objects.home_memory_button, (bool)objects.home_memory_button->user_data); + Themes::recolorText(objects.home_lora_label, db.config.lora.tx_enabled); + Themes::recolorText(objects.home_bell_label, db.uiConfig.alert_enabled || !db.silent); + Themes::recolorText(objects.home_location_label, + db.config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED); + Themes::recolorText(objects.home_wlan_label, db.config.network.wifi_enabled); + Themes::recolorText(objects.home_mqtt_label, db.module_config.mqtt.enabled); + Themes::recolorText(objects.home_sd_card_label, cardDetected); + Themes::recolorText(objects.home_memory_label, (bool)objects.home_memory_button->user_data); + + lv_opa_t opa = (Themes::get() == Themes::eDark) ? 0 : 255; + lv_obj_set_style_bg_img_recolor_opa(objects.home_button, opa, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(objects.nodes_button, opa, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(objects.groups_button, opa, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(objects.messages_button, opa, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(objects.map_button, opa, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(objects.settings_button, opa, LV_PART_MAIN | LV_STATE_DEFAULT); + + for (int i = 0; i < c_max_channels; i++) { + if (db.channel[i].role != meshtastic_Channel_Role_DISABLED) + updateGroupChannel(i); + } +} + +void TFTView_480x222::ui_events_init(void) +{ + // just a test to implement callback via non-static lambda function + auto ui_event_HomeButton = [](lv_event_t *e) { + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + TFTView_480x222 &view = *static_cast(e->user_data); + view.ui_set_active(objects.home_button, objects.home_panel, objects.top_panel); + } else if (event_code == LV_EVENT_LONG_PRESSED) { + if (THIS->MeshtasticView::getState() >= THIS->MeshtasticView::eConfigComplete) { + // force re-sync with node + THIS->controller->setConfigRequested(true); + THIS->notifyResync(true); + } + } + }; + + // main button events + lv_obj_add_event_cb(objects.home_button, ui_event_HomeButton, LV_EVENT_ALL, this); // uses lambda above + lv_obj_add_event_cb(objects.nodes_button, this->ui_event_NodesButton, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.groups_button, this->ui_event_GroupsButton, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.messages_button, this->ui_event_MessagesButton, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.map_button, this->ui_event_MapButton, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.settings_button, this->ui_event_SettingsButton, LV_EVENT_ALL, NULL); + + // Focus handlers for main buttons (green highlight on focus) + lv_obj_add_event_cb(objects.home_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.home_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.nodes_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.nodes_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.groups_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.groups_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.messages_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.messages_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.map_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.map_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + lv_obj_add_event_cb(objects.settings_button, this->ui_event_MainButtonFocus, LV_EVENT_FOCUSED, NULL); + lv_obj_add_event_cb(objects.settings_button, this->ui_event_MainButtonFocus, LV_EVENT_DEFOCUSED, NULL); + + // Register callback for backspace -> home navigation + I2CKeyboardInputDriver::setNavigateHomeCallback([]() { + if (objects.home_button) { + lv_group_focus_obj(objects.home_button); + } + }); + + // Register callback for alt+encoder scrolling in messages + I2CKeyboardInputDriver::setScrollCallback([](int direction) { + if (THIS && THIS->activeMsgContainer && THIS->activePanel == objects.messages_panel) { + int32_t scroll_amount = 80; + // direction > 0 means scroll down (content moves up), < 0 means scroll up + lv_obj_scroll_by(THIS->activeMsgContainer, 0, direction > 0 ? -scroll_amount : scroll_amount, LV_ANIM_ON); + } + }); + + // home buttons + lv_obj_add_event_cb(objects.home_mail_button, this->ui_event_EnvelopeButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.home_nodes_button, this->ui_event_OnlineNodesButton, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.home_time_button, this->ui_event_TimeButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.home_lora_button, this->ui_event_LoRaButton, LV_EVENT_LONG_PRESSED, NULL); + lv_obj_add_event_cb(objects.home_bell_button, this->ui_event_BellButton, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.home_location_button, this->ui_event_LocationButton, LV_EVENT_LONG_PRESSED, NULL); + lv_obj_add_event_cb(objects.home_wlan_button, this->ui_event_WLANButton, LV_EVENT_LONG_PRESSED, NULL); + lv_obj_add_event_cb(objects.home_mqtt_button, this->ui_event_MQTTButton, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.home_sd_card_button, this->ui_event_SDCardButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.home_memory_button, this->ui_event_MemoryButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.home_qr_button, this->ui_event_QrButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.home_cancel_qr_button, this->ui_event_CancelQrButton, LV_EVENT_CLICKED, NULL); + + // node and channel buttons + lv_obj_add_event_cb(objects.node_button, ui_event_NodeButton, LV_EVENT_ALL, (void *)ownNode); + + // 8 channel buttons + lv_obj_add_event_cb(objects.channel_button0, ui_event_ChannelButton, LV_EVENT_ALL, (void *)0); + lv_obj_add_event_cb(objects.channel_button1, ui_event_ChannelButton, LV_EVENT_ALL, (void *)1); + lv_obj_add_event_cb(objects.channel_button2, ui_event_ChannelButton, LV_EVENT_ALL, (void *)2); + lv_obj_add_event_cb(objects.channel_button3, ui_event_ChannelButton, LV_EVENT_ALL, (void *)3); + lv_obj_add_event_cb(objects.channel_button4, ui_event_ChannelButton, LV_EVENT_ALL, (void *)4); + lv_obj_add_event_cb(objects.channel_button5, ui_event_ChannelButton, LV_EVENT_ALL, (void *)5); + lv_obj_add_event_cb(objects.channel_button6, ui_event_ChannelButton, LV_EVENT_ALL, (void *)6); + lv_obj_add_event_cb(objects.channel_button7, ui_event_ChannelButton, LV_EVENT_ALL, (void *)7); + + // message popup + lv_obj_add_event_cb(objects.msg_popup_button, this->ui_event_MsgPopupButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.msg_popup_panel, this->ui_event_MsgPopupButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.msg_restore_button, this->ui_event_MsgRestoreButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.msg_restore_panel, this->ui_event_MsgRestoreButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.alert_panel, this->ui_event_AlertButton, LV_EVENT_CLICKED, NULL); + + // keyboard + lv_obj_add_event_cb(objects.keyboard, ui_event_Keyboard, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(objects.keyboard_button_0, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)0); + lv_obj_add_event_cb(objects.keyboard_button_1, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)1); + lv_obj_add_event_cb(objects.keyboard_button_2, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)2); + lv_obj_add_event_cb(objects.keyboard_button_3, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)3); + lv_obj_add_event_cb(objects.keyboard_button_4, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)4); + lv_obj_add_event_cb(objects.keyboard_button_5, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)5); + lv_obj_add_event_cb(objects.keyboard_button_6, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)6); + lv_obj_add_event_cb(objects.keyboard_button_7, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)7); + lv_obj_add_event_cb(objects.keyboard_button_8, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)8); + lv_obj_add_event_cb(objects.keyboard_button_9, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)9); + lv_obj_add_event_cb(objects.keyboard_button_10, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)10); + lv_obj_add_event_cb(objects.keyboard_button_11, ui_event_KeyboardButton, LV_EVENT_CLICKED, (void *)11); + + // message text area + lv_obj_add_event_cb(objects.message_input_area, ui_event_message_ready, LV_EVENT_ALL, NULL); + + // basic settings buttons + lv_obj_add_event_cb(objects.basic_settings_user_button, ui_event_user_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_role_button, ui_event_role_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_region_button, ui_event_region_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_modem_preset_button, ui_event_preset_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_wifi_button, ui_event_wifi_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_language_button, ui_event_language_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_channel_button, ui_event_channel_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_timeout_button, ui_event_timeout_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_screen_lock_button, ui_event_screen_lock_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_brightness_button, ui_event_brightness_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_theme_button, ui_event_theme_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_calibration_button, ui_event_calibration_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_input_button, ui_event_input_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_alert_button, ui_event_alert_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_backup_restore_button, ui_event_backup_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_reset_button, ui_event_reset_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.basic_settings_reboot_button, ui_event_reboot_button, LV_EVENT_CLICKED, NULL); + + lv_obj_add_event_cb(objects.reboot_button, ui_event_device_reboot_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.progmode_button, ui_event_device_progmode_button, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.shutdown_button, ui_event_device_shutdown_button, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.cancel_reboot_button, ui_event_device_cancel_button, LV_EVENT_CLICKED, NULL); + + // sliders + lv_obj_add_event_cb(objects.screen_timeout_slider, ui_event_screen_timeout_slider, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(objects.brightness_slider, ui_event_brightness_slider, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(objects.frequency_slot_slider, ui_event_frequency_slot_slider, LV_EVENT_VALUE_CHANGED, NULL); + + // dropdown + lv_obj_add_event_cb(objects.settings_modem_preset_dropdown, ui_event_modem_preset_dropdown, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(objects.setup_region_dropdown, ui_event_setup_region_dropdown, LV_EVENT_VALUE_CHANGED, NULL); + + // OK / Cancel widget for basic settings dialog + lv_obj_add_event_cb(objects.obj2__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj2__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj3__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj3__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj4__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj4__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj5__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj5__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj6__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj6__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj7__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj7__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj8__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj8__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj9__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj9__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj10__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj10__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj11__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj11__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj12__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj12__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj13__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj13__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj14__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj14__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj15__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj15__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj16__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj16__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj17__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj17__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj18__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj18__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj21__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj21__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj27__ok_button_w, ui_event_ok, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.obj27__cancel_button_w, ui_event_cancel, LV_EVENT_CLICKED, 0); + + // modify channel buttons + lv_obj_add_event_cb(objects.settings_channel0_button, ui_event_modify_channel, LV_EVENT_ALL, (void *)0); + lv_obj_add_event_cb(objects.settings_channel1_button, ui_event_modify_channel, LV_EVENT_ALL, (void *)1); + lv_obj_add_event_cb(objects.settings_channel2_button, ui_event_modify_channel, LV_EVENT_ALL, (void *)2); + lv_obj_add_event_cb(objects.settings_channel3_button, ui_event_modify_channel, LV_EVENT_ALL, (void *)3); + lv_obj_add_event_cb(objects.settings_channel4_button, ui_event_modify_channel, LV_EVENT_ALL, (void *)4); + lv_obj_add_event_cb(objects.settings_channel5_button, ui_event_modify_channel, LV_EVENT_ALL, (void *)5); + lv_obj_add_event_cb(objects.settings_channel6_button, ui_event_modify_channel, LV_EVENT_ALL, (void *)6); + lv_obj_add_event_cb(objects.settings_channel7_button, ui_event_modify_channel, LV_EVENT_ALL, (void *)7); + // delete channel button + lv_obj_add_event_cb(objects.settings_modify_trash_button, ui_event_delete_channel, LV_EVENT_CLICKED, NULL); + // generate PSK + lv_obj_add_event_cb(objects.settings_modify_channel_key_generate_button, ui_event_generate_psk, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.settings_modify_channel_qr_button, ui_event_qr_code, LV_EVENT_CLICKED, NULL); + + // screen + lv_obj_add_event_cb(objects.calibration_screen, ui_event_calibration_screen_loaded, LV_EVENT_SCREEN_LOADED, (void *)7); + lv_obj_add_event_cb(objects.screen_lock_button_matrix, ui_event_pin_screen_button, LV_EVENT_ALL, 0); + + lv_obj_add_event_cb(objects.settings_backup_checkbox, ui_event_backup_restore_radio_button, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.settings_restore_checkbox, ui_event_backup_restore_radio_button, LV_EVENT_ALL, NULL); + + // map settings and navigation + lv_obj_add_event_cb(objects.main_screen, ui_screen_event_cb, LV_EVENT_GESTURE, NULL); + lv_obj_add_event_cb(objects.arrow_up_button, ui_event_arrow, LV_EVENT_CLICKED, (void *)8); + lv_obj_add_event_cb(objects.arrow_left_button, ui_event_arrow, LV_EVENT_CLICKED, (void *)4); + lv_obj_add_event_cb(objects.arrow_right_button, ui_event_arrow, LV_EVENT_CLICKED, (void *)6); + lv_obj_add_event_cb(objects.arrow_down_button, ui_event_arrow, LV_EVENT_CLICKED, (void *)2); + lv_obj_add_event_cb(objects.nav_button, ui_event_navHome, LV_EVENT_ALL, NULL); + lv_obj_add_event_cb(objects.zoom_slider, ui_event_zoomSlider, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(objects.zoom_in_button, ui_event_zoomIn, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.zoom_out_button, ui_event_zoomOut, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.gps_lock_button, ui_event_lockGps, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.map_brightness_slider, ui_event_mapBrightnessSlider, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(objects.map_contrast_slider, ui_event_mapContrastSlider, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(objects.map_style_dropdown, ui_event_map_style_dropdown, LV_EVENT_VALUE_CHANGED, NULL); + + // tools buttons + lv_obj_add_event_cb(objects.tools_mesh_detector_button, ui_event_mesh_detector, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.tools_signal_scanner_button, ui_event_signal_scanner, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.tools_trace_route_button, ui_event_trace_route, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.tools_neighbors_button, ui_event_node_details, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.tools_statistics_button, ui_event_statistics, LV_EVENT_ALL, 0); + lv_obj_add_event_cb(objects.tools_packet_log_button, ui_event_packet_log, LV_EVENT_ALL, 0); + // tools + lv_obj_add_event_cb(objects.detector_start_button, ui_event_mesh_detector_start, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.signal_scanner_node_button, ui_event_signal_scanner_node, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.signal_scanner_start_button, ui_event_signal_scanner_start, LV_EVENT_ALL, 0); + lv_obj_add_event_cb(objects.trace_route_to_button, ui_event_trace_route_to, LV_EVENT_CLICKED, 0); + lv_obj_add_event_cb(objects.trace_route_start_button, ui_event_trace_route_start, LV_EVENT_CLICKED, 0); +} + +#if 0 // defined above as lambda function for tests +void TDeckGUI::ui_event_HomeButton(lv_event_t * e) { + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + TDeckGUI::instance()->ui_set_active(objects.home_button, objects.home_panel, objects.top_panel); + } +} +#endif + +void TFTView_480x222::timer_event_reboot(lv_timer_t *timer) +{ + ILOG_INFO("Rebooting..."); + THIS->controller->stop(); + delay(4000); +#if defined(ARCH_PORTDUINO) + extern void reboot(); + reboot(); +#elif defined(ARCH_ESP32) + esp_restart(); +#else + // TODO: implement for other platforms +#endif +} + +void TFTView_480x222::timer_event_shutdown(lv_timer_t *timer) +{ + ILOG_INFO("Shutdown..."); + THIS->controller->stop(); + delay(1000); +#if defined(ARCH_PORTDUINO) + exit(0); +#elif defined(ARCH_ESP32) + esp_deep_sleep_start(); +#else + // TODO: implement for other platforms +#endif +} + +void TFTView_480x222::timer_event_programming_mode(lv_timer_t *timer) +{ + if (THIS->state == eBooting) + THIS->state = MeshtasticView::eBootScreenDone; + else if (THIS->state == eHoldingBootLogo) { + lv_obj_add_flag(objects.boot_logo_arc, LV_OBJ_FLAG_HIDDEN); + THIS->state = MeshtasticView::eEnterProgrammingMode; + THIS->enterProgrammingMode(); + } + lv_obj_remove_event_cb(objects.boot_logo_button, ui_event_LogoButton); +} + +void TFTView_480x222::ui_event_LogoButton(lv_event_t *e) +{ + + static uint32_t start = 0; + static lv_anim_t anim; + static auto animCB = [](void *var, int32_t v) { lv_arc_set_bg_end_angle((lv_obj_t *)var, v); }; + + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + lv_anim_del(&objects.boot_logo_arc, animCB); + lv_obj_add_flag(objects.boot_logo_arc, LV_OBJ_FLAG_HIDDEN); + if (millis() - start > 800) { + THIS->state = MeshtasticView::eEnterProgrammingMode; + THIS->enterProgrammingMode(); + } else { + lv_obj_add_flag(objects.boot_logo_arc, LV_OBJ_FLAG_HIDDEN); + THIS->state = MeshtasticView::eBootScreenDone; + } + } else if (event_code == LV_EVENT_LONG_PRESSED) { + if (THIS->state != MeshtasticView::eHoldingBootLogo) { + THIS->state = MeshtasticView::eHoldingBootLogo; + start = millis(); + + lv_obj_clear_flag(objects.boot_logo_arc, LV_OBJ_FLAG_HIDDEN); + lv_anim_init(&anim); + lv_anim_set_var(&anim, objects.boot_logo_arc); + lv_anim_set_values(&anim, 0, 360); + lv_anim_set_duration(&anim, 800); + lv_anim_set_exec_cb(&anim, animCB); + lv_anim_set_path_cb(&anim, lv_anim_path_linear); + lv_anim_start(&anim); + } + } +} + +void TFTView_480x222::ui_event_BluetoothButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_LONG_PRESSED) { + ILOG_INFO("leaving programming mode"); + lv_label_set_text(objects.meshtastic_url, _("Rebooting ...")); + lv_label_set_text(objects.firmware_label, ""); + lv_obj_remove_flag(objects.boot_logo_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.bluetooth_button, LV_OBJ_FLAG_HIDDEN); + + meshtastic_Config_BluetoothConfig &bluetooth = THIS->db.config.bluetooth; + bluetooth.enabled = false; + THIS->controller->sendConfig(meshtastic_Config_BluetoothConfig{bluetooth}, THIS->ownNode); + } +} + +// Focus handler for main menu buttons - applies green highlight on focus +void TFTView_480x222::ui_event_MainButtonFocus(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + lv_obj_t *btn = (lv_obj_t *)lv_event_get_target(e); + + if (event_code == LV_EVENT_FOCUSED) { + // Apply green highlight when button receives focus + lv_obj_set_style_bg_color(btn, colorMesh, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor(btn, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(btn, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + } else if (event_code == LV_EVENT_DEFOCUSED) { + // Remove highlight when focus leaves (unless it's the active button) + if (btn != THIS->activeButton) { + lv_obj_set_style_bg_color(btn, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); + if (Themes::get() == Themes::eDark) + lv_obj_set_style_bg_img_recolor_opa(btn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor(btn, colorGray, LV_PART_MAIN | LV_STATE_DEFAULT); + } + } +} + +// Global key handler for navigation - catches LV_KEY_HOME to focus side menu +void TFTView_480x222::ui_event_GlobalKeyHandler(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_KEY) { + uint32_t key = lv_event_get_key(e); + if (key == LV_KEY_HOME) { + // Focus the home button to enable side menu navigation + if (objects.home_button) { + lv_group_focus_obj(objects.home_button); + } + } + } +} + +void TFTView_480x222::ui_event_NodesButton(lv_event_t *e) +{ + static bool ignoreClicked = false; + static bool filterNeedsUpdate = false; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + if (filterNeedsUpdate) { + THIS->updateNodesFiltered(true); + THIS->updateNodesStatus(); + lv_obj_scroll_to_view(objects.node_panel, LV_ANIM_ON); + if (THIS->map) { + THIS->map->forceRedraw(true); + } + filterNeedsUpdate = false; + } + } else if (event_code == LV_EVENT_LONG_PRESSED) { + filterNeedsUpdate = true; + ignoreClicked = true; + THIS->ui_set_active(objects.nodes_button, objects.node_options_panel, objects.top_node_options_panel); + } +} + +void TFTView_480x222::ui_event_NodeButton(lv_event_t *e) +{ + static bool animRunning = false; + static auto deleted_cb = [](_lv_anim_t *) { animRunning = false; }; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && !animRunning) { + uint32_t nodeNum = (unsigned long)e->user_data; + if (!nodeNum) // event-handler for own node has value 0 in user_data + nodeNum = THIS->ownNode; + lv_obj_t *panel = THIS->nodes[nodeNum]; + if (currentPanel) { + // create animation to shrink other panel + animRunning = true; + static lv_anim_t a; + int32_t height = lv_obj_get_height(currentPanel); + lv_anim_init(&a); + lv_anim_set_var(&a, currentPanel); + lv_anim_set_values(&a, height, 136 - height); + lv_anim_set_duration(&a, 200); + lv_anim_set_exec_cb(&a, ui_anim_node_panel_cb); + lv_anim_set_path_cb(&a, lv_anim_path_linear); + lv_anim_set_deleted_cb(&a, deleted_cb); + lv_anim_start(&a); + } + if (panel != currentPanel) { + // create animation to enlarge node panel + animRunning = true; + static lv_anim_t a; + int32_t height = lv_obj_get_height(panel); + lv_anim_init(&a); + lv_anim_set_var(&a, panel); + lv_anim_set_values(&a, height, 136 - height); + lv_anim_set_duration(&a, 200); + lv_anim_set_exec_cb(&a, ui_anim_node_panel_cb); + lv_anim_set_path_cb(&a, lv_anim_path_linear); + lv_anim_set_deleted_cb(&a, deleted_cb); + lv_anim_start(&a); + currentPanel = panel; + currentNode = nodeNum; + } else { + currentPanel = nullptr; + currentNode = 0; + } + if (THIS->chooseNodeSignalScanner) { + THIS->chooseNodeSignalScanner = false; + ui_event_signal_scanner(NULL); + // restore previous filter + lv_dropdown_set_selected(objects.nodes_filter_hops_dropdown, THIS->selectedHops); + THIS->updateNodesFiltered(true); + THIS->updateNodesStatus(); + } else if (THIS->chooseNodeTraceRoute) { + THIS->chooseNodeTraceRoute = false; + ui_event_trace_route(NULL); + } + } else if (event_code == LV_EVENT_LONG_PRESSED) { + // set color and text of clicked node + uint32_t nodeNum = (unsigned long)e->user_data; + bool isMessagable = !((unsigned long)(THIS->nodes[nodeNum]->LV_OBJ_IDX(node_img_idx)->user_data) == eRole::unmessagable); + if (nodeNum != THIS->ownNode && isMessagable) + THIS->showMessages(nodeNum); + } +} + +void TFTView_480x222::ui_event_GroupsButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + THIS->ui_set_active(objects.groups_button, objects.groups_panel, objects.top_groups_panel); + } +} + +void TFTView_480x222::ui_event_ChannelButton(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + uint8_t ch = (uint8_t)(unsigned long)e->user_data; + if (THIS->db.channel[ch].role != meshtastic_Channel_Role_DISABLED) { + if (THIS->messagesRestored) { + THIS->showMessages(ch); + } else { + lv_obj_clear_flag(objects.msg_restore_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.msg_restore_button); + } + } + } else if (event_code == LV_EVENT_LONG_PRESSED) { + // toggle mute channel + uint8_t ch = (uint8_t)(unsigned long)e->user_data; + bool mute = THIS->db.channel[ch].settings.module_settings.is_muted; + THIS->db.channel[ch].settings.module_settings.is_muted = !mute; + THIS->updateChannelConfig(THIS->db.channel[ch]); + THIS->controller->sendConfig(THIS->db.channel[ch], THIS->ownNode); + ignoreClicked = true; + } +} + +void TFTView_480x222::ui_event_MessagesButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + if (THIS->messagesRestored) { + THIS->ui_set_active(objects.messages_button, objects.chats_panel, objects.top_chats_panel); + } else { + lv_obj_clear_flag(objects.msg_restore_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.msg_restore_button); + } + } +} + +void TFTView_480x222::ui_event_MapButton(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + if (THIS->activePanel == objects.map_panel) { + // toggle navigation and zoom slider + static bool toggle = true; + toggle = !toggle; + if (toggle) { + // lv_obj_clear_flag(objects.zoom_slider, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(objects.gps_lock_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(objects.zoom_in_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(objects.zoom_out_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(objects.navigation_panel, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(objects.zoom_slider, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.gps_lock_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.zoom_in_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.zoom_out_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.navigation_panel, LV_OBJ_FLAG_HIDDEN); + } + } else { + THIS->ui_set_active(objects.map_button, objects.map_panel, objects.top_map_panel); + THIS->loadMap(); + lv_group_focus_obj(objects.nav_button); + } + lv_obj_add_flag(objects.map_osd_panel, LV_OBJ_FLAG_HIDDEN); + } else if (event_code == LV_EVENT_LONG_PRESSED && THIS->activeSettings == eNone) { + lv_obj_clear_flag(objects.map_osd_panel, LV_OBJ_FLAG_HIDDEN); + ignoreClicked = true; + } +} + +void TFTView_480x222::ui_event_SettingsButton(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + static bool advancedMode = false; + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + if (THIS->db.uiConfig.settings_lock) { + lv_obj_add_flag(objects.tab_page_basic_settings, LV_OBJ_FLAG_HIDDEN); + THIS->ui_set_active(objects.settings_button, objects.controller_panel, objects.top_settings_panel); + lv_screen_load_anim(objects.lock_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + } else { + THIS->ui_set_active(objects.settings_button, objects.controller_panel, objects.top_settings_panel); + } + } else if (event_code == LV_EVENT_LONG_PRESSED && !advancedMode && THIS->activeSettings == eNone) { + ILOG_DEBUG("screen locked"); + screenLocked = true; + screenUnlockRequest = false; + ignoreClicked = true; + } else if (event_code == LV_EVENT_LONG_PRESSED && advancedMode && THIS->activeSettings == eNone) { + advancedMode = !advancedMode; + THIS->ui_set_active(objects.settings_button, ui_AdvancedSettingsPanel, objects.top_advanced_settings_panel); + } +} + +void TFTView_480x222::ui_event_ChatButton(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + lv_obj_t *target = lv_event_get_target_obj(e); + if (event_code == LV_EVENT_LONG_PRESSED) { + ignoreClicked = true; + lv_obj_t *delBtn = target->LV_OBJ_IDX(1); + lv_obj_clear_flag(delBtn, LV_OBJ_FLAG_HIDDEN); + } else if (event_code == LV_EVENT_DEFOCUSED || event_code == LV_EVENT_LEAVE) { + lv_obj_t *delBtn = target->LV_OBJ_IDX(1); + lv_obj_add_flag(delBtn, LV_OBJ_FLAG_HIDDEN); + } else if (event_code == LV_EVENT_CLICKED) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + lv_obj_set_style_border_color(target, colorMidGray, LV_PART_MAIN | LV_STATE_DEFAULT); + + uint32_t channelOrNode = (unsigned long)e->user_data; + if (channelOrNode < c_max_channels) { + uint8_t ch = (uint8_t)channelOrNode; + THIS->showMessages(ch); + THIS->ui_set_active(objects.messages_button, objects.messages_panel, objects.top_group_chat_panel); + } else { + uint32_t nodeNum = channelOrNode; + THIS->showMessages(nodeNum); + THIS->ui_set_active(objects.messages_button, objects.messages_panel, objects.top_messages_panel); + } + } +} + +/** + * @brief Del button pressed, handle deletion or clearance of chat and messages panel + * + * @param e + */ +void TFTView_480x222::ui_event_ChatDelButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + lv_obj_t *target = lv_event_get_target_obj(e); + lv_obj_add_flag(target, LV_OBJ_FLAG_HIDDEN); + + uint32_t channelOrNode = (unsigned long)e->user_data; + if (channelOrNode < c_max_channels) { + THIS->eraseChat(channelOrNode); + THIS->controller->removeTextMessages(THIS->ownNode, UINT32_MAX, channelOrNode); + } else { + THIS->eraseChat(channelOrNode); + THIS->applyNodesFilter(channelOrNode); + THIS->controller->removeTextMessages(THIS->ownNode, channelOrNode, 0); + } + THIS->activeMsgContainer = objects.messages_container; + THIS->updateActiveChats(); + if (THIS->chats.empty()) { + // last chat was deleted, now we can get rid of all logs :) + THIS->controller->removeTextMessages(0, 0, 0); + } + } +} + +/** + * @brief hide msgPopupPanel on touch; goto message on button press + * + */ +void TFTView_480x222::ui_event_MsgPopupButton(lv_event_t *e) +{ + lv_obj_t *target = lv_event_get_target_obj(e); + if (target == objects.msg_popup_panel) { + THIS->hideMessagePopup(); + } else { // msg button was clicked + uint32_t channelOrNode = (unsigned long)objects.msg_popup_button->user_data; + if (channelOrNode < c_max_channels) { + uint8_t ch = (uint8_t)channelOrNode; + THIS->showMessages(ch); + } else { + uint32_t nodeNum = channelOrNode; + THIS->showMessages(nodeNum); + } + } +} + +/** + * @brief hide msgRestorePanel on touch + * + */ +void TFTView_480x222::ui_event_MsgRestoreButton(lv_event_t *e) +{ + lv_obj_add_flag(objects.msg_restore_panel, LV_OBJ_FLAG_HIDDEN); +} + +void TFTView_480x222::ui_event_EnvelopeButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + if (!THIS->messagesRestored) { + lv_obj_clear_flag(objects.msg_restore_panel, LV_OBJ_FLAG_HIDDEN); + return; + } + if (THIS->configComplete) + THIS->ui_set_active(objects.messages_button, objects.chats_panel, objects.top_chats_panel); + } +} + +void TFTView_480x222::ui_event_AlertButton(lv_event_t *e) +{ + lv_obj_add_flag(objects.alert_panel, LV_OBJ_FLAG_HIDDEN); +} + +void TFTView_480x222::ui_event_OnlineNodesButton(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->configComplete) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + lv_obj_set_state(objects.nodes_filter_offline_switch, LV_STATE_CHECKED, true); + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + THIS->updateNodesFiltered(true); + } else if (event_code == LV_EVENT_LONG_PRESSED) { + // reset all filters + lv_obj_set_state(objects.nodes_filter_unknown_switch, LV_STATE_CHECKED, false); + lv_obj_set_state(objects.nodes_filter_offline_switch, LV_STATE_CHECKED, false); + lv_obj_set_state(objects.nodes_filter_public_key_switch, LV_STATE_CHECKED, false); + lv_obj_set_state(objects.nodes_filter_position_switch, LV_STATE_CHECKED, false); + lv_dropdown_set_selected(objects.nodes_filter_channel_dropdown, 0); + lv_dropdown_set_selected(objects.nodes_filter_hops_dropdown, 0); + lv_textarea_set_text(objects.nodes_filter_name_area, ""); + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + THIS->updateNodesFiltered(true); + THIS->storeNodeOptions(); + ignoreClicked = true; + } +} + +void TFTView_480x222::ui_event_TimeButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + // toggle date/time <-> uptime display + uint32_t toggle = (unsigned long)objects.home_time_button->user_data; + objects.home_time_button->user_data = (void *)(1 - toggle); + THIS->updateTime(); + } +} + +void TFTView_480x222::ui_event_LoRaButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_LONG_PRESSED && THIS->db.config.has_lora) { + // toggle lora tx on/off + meshtastic_Config_LoRaConfig &lora = THIS->db.config.lora; + lora.tx_enabled = !lora.tx_enabled; + THIS->controller->sendConfig(meshtastic_Config_LoRaConfig{lora}); + THIS->showLoRaFrequency(lora); + } +} + +void TFTView_480x222::ui_event_BellButton(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->db.module_config.has_external_notification) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + // set banner and sound on + if (THIS->db.silent && (bool)objects.home_bell_button->user_data) { + if (THIS->db.uiConfig.ring_tone_id == 0) { + THIS->db.uiConfig.ring_tone_id = 1; + } + THIS->db.silent = false; + THIS->db.uiConfig.alert_enabled = true; + THIS->controller->sendConfig(ringtone[THIS->db.uiConfig.ring_tone_id].rtttl, THIS->ownNode); + objects.home_bell_button->user_data = (void *)false; + } + // toggle sound only + else if (THIS->db.uiConfig.alert_enabled && !THIS->db.silent) { + if (THIS->db.uiConfig.ring_tone_id == 0) { + THIS->db.uiConfig.ring_tone_id = 1; + } + THIS->db.uiConfig.alert_enabled = false; + THIS->controller->sendConfig(ringtone[THIS->db.uiConfig.ring_tone_id].rtttl, THIS->ownNode); + } + // toggle banner only + else if (!THIS->db.uiConfig.alert_enabled && !THIS->db.silent) { + THIS->db.silent = true; + THIS->db.uiConfig.alert_enabled = true; + THIS->controller->sendConfig(ringtone[0].rtttl, THIS->ownNode); + } + // toggle banner & sound + else { + if (THIS->db.uiConfig.ring_tone_id == 0) { + THIS->db.uiConfig.ring_tone_id = 1; + } + THIS->db.silent = false; + THIS->db.uiConfig.alert_enabled = true; + THIS->controller->sendConfig(ringtone[THIS->db.uiConfig.ring_tone_id].rtttl, THIS->ownNode); + } + THIS->setBellText(THIS->db.uiConfig.alert_enabled, !THIS->db.silent); + THIS->controller->storeUIConfig(THIS->db.uiConfig); + } else if (event_code == LV_EVENT_LONG_PRESSED) { + ignoreClicked = true; + if ((bool)objects.home_bell_button->user_data) { + if (THIS->db.uiConfig.ring_tone_id == 0) { + THIS->db.uiConfig.ring_tone_id = 1; + } + THIS->db.silent = false; + THIS->db.uiConfig.alert_enabled = true; + THIS->controller->sendConfig(ringtone[THIS->db.uiConfig.ring_tone_id].rtttl, THIS->ownNode); + objects.home_bell_button->user_data = (void *)false; + } else { + THIS->db.silent = true; + THIS->db.uiConfig.alert_enabled = false; + THIS->controller->sendConfig(ringtone[0].rtttl, THIS->ownNode); + objects.home_bell_button->user_data = (void *)true; + } + THIS->setBellText(THIS->db.uiConfig.alert_enabled, !THIS->db.silent); + THIS->controller->storeUIConfig(THIS->db.uiConfig); + } +} + +void TFTView_480x222::ui_event_LocationButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_PRESSED && THIS->configComplete) { + // TODO: figure out if there is a way to enabled GPS without a reboot (ala triple-click) + // over phone api and switch between enabled/disabled with short press + // uint32_t toggle = (unsigned long)objects.home_location_button->user_data; + // objects.home_location_button->user_data = (void *)(1 - toggle); + // Themes::recolorButton(objects.home_location_button, toggle); + } else if (event_code == LV_EVENT_LONG_PRESSED && THIS->configComplete) { + // toggle GPS not_present <-> enabled + uint32_t toggle = (unsigned long)objects.home_location_button->user_data; + objects.home_location_button->user_data = (void *)(1 - toggle); + + meshtastic_Config_PositionConfig &position = THIS->db.config.position; + if (position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) + position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED; + else { + position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT; + } + Themes::recolorButton(objects.home_location_button, + position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED); + THIS->controller->sendConfig(meshtastic_Config_PositionConfig{position}); + THIS->notifyReboot(true); + } +} + +void TFTView_480x222::ui_event_WLANButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_LONG_PRESSED && THIS->db.config.has_network) { + if ((THIS->db.config.network.wifi_ssid[0] == '\0' || THIS->db.config.network.wifi_psk[0] == '\0') && + !THIS->db.connectionStatus.wifi.status.is_connected && + !THIS->db.config.network.eth_enabled) { // TODO: this is a workaround for bug in portduino layer + // open settings dialog + lv_textarea_set_text(objects.settings_wifi_ssid_textarea, THIS->db.config.network.wifi_ssid); + lv_textarea_set_text(objects.settings_wifi_password_textarea, THIS->db.config.network.wifi_psk); + lv_obj_clear_flag(objects.settings_wifi_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_wifi_ssid_textarea); + THIS->disablePanel(objects.home_panel); + lv_obj_clear_state(objects.home_wlan_button, LV_STATE_PRESSED); + THIS->activeSettings = eWifi; + } else { + // toggle WLAN on/off + uint32_t toggle = (unsigned long)objects.home_wlan_button->user_data; + objects.home_wlan_button->user_data = (void *)(1 - toggle); + meshtastic_Config_NetworkConfig &network = THIS->db.config.network; + network.wifi_enabled = !network.wifi_enabled; + Themes::recolorButton(objects.home_wlan_button, network.wifi_enabled); + THIS->controller->sendConfig(meshtastic_Config_NetworkConfig{network}); + THIS->notifyReboot(true); + } + } +} + +void TFTView_480x222::ui_event_MQTTButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_LONG_PRESSED && THIS->configComplete) { + // toggle MQTT on/off + uint32_t toggle = (unsigned long)objects.home_mqtt_button->user_data; + objects.home_mqtt_button->user_data = (void *)(1 - toggle); + + meshtastic_ModuleConfig_MQTTConfig &mqtt = THIS->db.module_config.mqtt; + mqtt.enabled = !mqtt.enabled; + Themes::recolorButton(objects.home_mqtt_button, mqtt.enabled); + THIS->controller->sendConfig(meshtastic_ModuleConfig_MQTTConfig{mqtt}); + THIS->notifyReboot(true); + } +} + +void TFTView_480x222::ui_event_SDCardButton(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + THIS->updateSDCard(); + } else if (event_code == LV_EVENT_LONG_PRESSED) { + if (THIS->formatSD) { + ignoreClicked = true; + THIS->formatSDCard(); + } + } +} + +void TFTView_480x222::ui_event_MemoryButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + // toggle memory display updates + uint32_t toggle = (unsigned long)objects.home_memory_button->user_data; + objects.home_memory_button->user_data = (void *)(1 - toggle); + Themes::recolorButton(objects.home_memory_button, !toggle); + Themes::recolorText(objects.home_memory_label, !toggle); + if ((unsigned long)objects.home_memory_button->user_data) { + THIS->updateFreeMem(); + } + } +} + +void TFTView_480x222::ui_event_QrButton(lv_event_t *e) +{ + meshtastic_SharedContact contact{.node_num = THIS->ownNode, .has_user = true, .user = THIS->db.user, .should_ignore = false}; + + meshtastic_Data_payload_t payload; + payload.size = pb_encode_to_bytes(payload.bytes, sizeof(payload.bytes), &meshtastic_SharedContact_msg, &contact); + std::string base64Https = THIS->pskToBase64(payload.bytes, payload.size); + for (char &c : base64Https) { + if (c == '+') + c = '-'; + else if (c == '/') + c = '_'; + else if (c == '=') + c = '\0'; + } + std::string qr = "https://meshtastic.org/v/#" + base64Https; + lv_obj_remove_flag(objects.home_show_qr_panel, LV_OBJ_FLAG_HIDDEN); + THIS->qr = THIS->showQrCode(objects.home_show_qr_panel, qr.c_str()); +} + +void TFTView_480x222::ui_event_CancelQrButton(lv_event_t *e) +{ + lv_obj_add_flag(objects.home_show_qr_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_delete(THIS->qr); + THIS->qr = nullptr; +} + +void TFTView_480x222::ui_event_BlankScreenButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + ILOG_DEBUG("screen unlocked by button"); + screenUnlockRequest = true; + } +} + +void TFTView_480x222::ui_event_KeyboardButton(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + uint32_t keyBtnIdx = (unsigned long)e->user_data; + switch (keyBtnIdx) { + case 0: + if (lv_obj_has_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN)) { + lv_obj_remove_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN); + THIS->showKeyboard(objects.message_input_area); + } else { + THIS->hideKeyboard(objects.messages_panel); + } + lv_group_focus_obj(objects.message_input_area); + return; // continue play animation, don't hide keyboard immediately + case 1: + THIS->showKeyboard(objects.settings_user_short_textarea); + lv_group_focus_obj(objects.settings_user_short_textarea); + break; + case 2: + THIS->showKeyboard(objects.settings_user_long_textarea); + lv_group_focus_obj(objects.settings_user_long_textarea); + break; + case 3: + THIS->showKeyboard(objects.settings_modify_channel_name_textarea); + lv_group_focus_obj(objects.settings_modify_channel_name_textarea); + break; + case 4: + THIS->showKeyboard(objects.settings_modify_channel_psk_textarea); + lv_group_focus_obj(objects.settings_modify_channel_psk_textarea); + break; + case 5: + THIS->showKeyboard(objects.nodes_filter_name_area); + lv_group_focus_obj(objects.nodes_filter_name_area); + break; + case 6: + THIS->showKeyboard(objects.nodes_hl_name_area); + lv_group_focus_obj(objects.nodes_hl_name_area); + break; + case 7: + THIS->showKeyboard(objects.settings_screen_lock_password_textarea); + lv_group_focus_obj(objects.settings_screen_lock_password_textarea); + break; + case 8: + THIS->showKeyboard(objects.settings_wifi_ssid_textarea); + lv_group_focus_obj(objects.settings_wifi_ssid_textarea); + break; + case 9: + THIS->showKeyboard(objects.settings_wifi_password_textarea); + lv_group_focus_obj(objects.settings_wifi_password_textarea); + break; + case 10: + THIS->showKeyboard(objects.setup_user_short_textarea); + lv_group_focus_obj(objects.setup_user_short_textarea); + break; + case 11: + THIS->showKeyboard(objects.setup_user_long_textarea); + lv_group_focus_obj(objects.setup_user_long_textarea); + break; + default: + ILOG_ERROR("missing keyboard <-> textarea assignment"); + } + lv_obj_has_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN) ? lv_obj_remove_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN) + : lv_obj_add_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN); + } +} + +/** + * handle events for virtual keyboard + */ +void TFTView_480x222::ui_event_Keyboard(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + lv_obj_t *kb = lv_event_get_target_obj(e); + uint32_t btn_id = lv_keyboard_get_selected_button(kb); + + switch (btn_id) { + case 22: { // enter (filtered out by one-liner text input area, so we replace it) + // lv_obj_t *ta = lv_keyboard_get_textarea(kb); + // lv_textarea_add_char(ta, ' '); + // lv_textarea_add_char(ta, CR_REPLACEMENT); + break; + } + case 35: { // keyboard + lv_keyboard_set_popovers(objects.keyboard, !lv_keyboard_get_popovers(kb)); + break; + } + case 36: { // left + break; + } + case 38: { // right + break; + } + case 39: { // checkmark + if (THIS->activePanel == objects.messages_panel) { + THIS->hideKeyboard(objects.messages_panel); + } else { + lv_obj_add_flag(kb, LV_OBJ_FLAG_HIDDEN); + } + lv_group_focus_obj(objects.message_input_area); + break; + } + default: + break; + // const char *txt = lv_keyboard_get_button_text(kb, btn_id); + } + } +} + +void TFTView_480x222::ui_event_message_ready(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_READY) { + char *txt = (char *)lv_textarea_get_text(objects.message_input_area); + uint32_t len = strlen(txt); + if (len) { + if (txt[len - 1] == ' ') { // use space+return combo to start new line in same message + lv_textarea_add_char(objects.message_input_area, CR_REPLACEMENT); + } else { + THIS->handleAddMessage(txt); + lv_textarea_set_text(objects.message_input_area, ""); + if (!lv_obj_has_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN)) { + THIS->hideKeyboard(objects.messages_panel); + } + lv_group_focus_obj(objects.message_input_area); + } + } + } else if (event_code == LV_EVENT_KEY) { + // Handle scrolling with encoder - safety checks to prevent boot loop + if (!THIS || !THIS->activeMsgContainer || THIS->activePanel != objects.messages_panel) { + return; + } + // Ensure message_input_area exists + if (!objects.message_input_area) { + return; + } + uint32_t key = lv_event_get_key(e); + // Only process UP/DOWN keys for scrolling + if (key != LV_KEY_UP && key != LV_KEY_DOWN) { + return; + } + // Scroll when textarea is empty OR when ALT modifier is held + bool altHeld = I2CKeyboardInputDriver::isAltModifierHeld(); + const char *txt = lv_textarea_get_text(objects.message_input_area); + bool isEmpty = (txt == nullptr) || (txt[0] == '\0'); + if (!altHeld && !isEmpty) { + return; // Let textarea handle cursor movement when typing (unless ALT held) + } + int32_t scroll_amount = 40; // pixels to scroll + if (key == LV_KEY_UP) { + lv_obj_scroll_by(THIS->activeMsgContainer, 0, scroll_amount, LV_ANIM_ON); + } else if (key == LV_KEY_DOWN) { + lv_obj_scroll_by(THIS->activeMsgContainer, 0, -scroll_amount, LV_ANIM_ON); + } + } +} + +// basic settings buttons + +void TFTView_480x222::ui_event_user_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + lv_textarea_set_text(objects.settings_user_short_textarea, THIS->db.short_name); + lv_textarea_set_text(objects.settings_user_long_textarea, THIS->db.long_name); + lv_obj_clear_flag(objects.settings_username_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_user_short_textarea); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eUsername; + } +} + +void TFTView_480x222::ui_event_role_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone && THIS->db.config.has_device) { + lv_dropdown_set_selected(objects.settings_device_role_dropdown, THIS->role2val(THIS->db.config.device.role)); + lv_obj_clear_flag(objects.settings_device_role_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_device_role_dropdown); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eDeviceRole; + } +} + +void TFTView_480x222::ui_event_region_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone && THIS->db.config.has_lora) { + lv_dropdown_set_selected(objects.settings_region_dropdown, THIS->db.config.lora.region - 1); + lv_obj_clear_flag(objects.settings_region_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_region_dropdown); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eRegion; + } +} + +void TFTView_480x222::ui_event_preset_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone && THIS->db.config.lora.use_preset) { + THIS->activeSettings = eModemPreset; + lv_dropdown_set_selected(objects.settings_modem_preset_dropdown, THIS->preset2val(THIS->db.config.lora.modem_preset)); + + char buf[60]; + sprintf(buf, _("FrequencySlot: %d (%g MHz)"), THIS->db.config.lora.channel_num, + LoRaPresets::getRadioFreq(THIS->db.config.lora.region, THIS->db.config.lora.modem_preset, + THIS->db.config.lora.channel_num)); + lv_label_set_text(objects.frequency_slot_label, buf); + + uint32_t numChannels = LoRaPresets::getNumChannels(THIS->db.config.lora.region, THIS->db.config.lora.modem_preset); + lv_slider_set_range(objects.frequency_slot_slider, 1, numChannels); + lv_slider_set_value(objects.frequency_slot_slider, THIS->db.config.lora.channel_num, LV_ANIM_OFF); + + lv_obj_clear_flag(objects.settings_modem_preset_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_modem_preset_dropdown); + THIS->disablePanel(objects.controller_panel); + } +} + +void TFTView_480x222::ui_event_wifi_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->db.config.has_network && THIS->activeSettings == eNone) { + lv_textarea_set_text(objects.settings_wifi_ssid_textarea, THIS->db.config.network.wifi_ssid); + lv_textarea_set_text(objects.settings_wifi_password_textarea, THIS->db.config.network.wifi_psk); + lv_obj_clear_flag(objects.settings_wifi_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_wifi_ssid_textarea); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eWifi; + } +} + +void TFTView_480x222::ui_event_language_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + lv_dropdown_set_selected(objects.settings_language_dropdown, THIS->language2val(THIS->db.uiConfig.language)); + lv_obj_clear_flag(objects.settings_language_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_language_dropdown); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eLanguage; + } +} + +void TFTView_480x222::ui_event_channel_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + // primary channel is not necessarily channel[0], setup ui with primary on top + int pos = 1; + for (int i = 0; i < c_max_channels; i++) { + meshtastic_Channel &ch = THIS->db.channel[i]; + if (ch.has_settings && ch.role != meshtastic_Channel_Role_DISABLED) { + const char *channelName = ch.settings.name; + if (ch.settings.name[0] == '\0' && ch.settings.psk.size == 1 && ch.settings.psk.bytes[0] == 0x01) { + channelName = LoRaPresets::modemPresetToString(THIS->db.config.lora.modem_preset); + } + if (ch.role == meshtastic_Channel_Role_PRIMARY) { + THIS->ch_label[0]->user_data = (void *)i; + lv_label_set_text(THIS->ch_label[0], channelName); + } else { + THIS->ch_label[pos]->user_data = (void *)i; + lv_label_set_text(THIS->ch_label[pos++], channelName); + } + } + } + for (int i = pos; i < c_max_channels; i++) { + THIS->ch_label[i]->user_data = (void *)-1; + lv_label_set_text(THIS->ch_label[i], _("")); + } + lv_obj_clear_flag(objects.settings_channel_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_channel0_button); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eChannel; + + // create scratch channels to store temporary changes until cancelled or applied + THIS->channel_scratch = new meshtastic_Channel[c_max_channels]; + for (int i = 0; i < c_max_channels; i++) { + THIS->channel_scratch[i] = THIS->db.channel[i]; + } + } +} + +void TFTView_480x222::ui_event_brightness_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + char buf[20]; + uint32_t brightness = round(THIS->db.uiConfig.screen_brightness * 100.0 / 255.0); + lv_snprintf(buf, sizeof(buf), _("Brightness: %d%%"), brightness); + lv_label_set_text(objects.settings_brightness_label, buf); + lv_slider_set_value(objects.brightness_slider, brightness, LV_ANIM_OFF); + lv_obj_clear_flag(objects.settings_brightness_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.brightness_slider); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eScreenBrightness; + } +} + +void TFTView_480x222::ui_event_theme_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + lv_dropdown_set_selected(objects.settings_theme_dropdown, THIS->db.uiConfig.theme); + lv_obj_clear_flag(objects.settings_theme_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_theme_dropdown); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eTheme; + } +} + +void TFTView_480x222::ui_event_calibration_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + lv_screen_load_anim(objects.calibration_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + } +} + +void TFTView_480x222::ui_event_timeout_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + uint32_t timeout = THIS->db.uiConfig.screen_timeout; + char buf[32]; + if (timeout == 0) + lv_snprintf(buf, sizeof(buf), _("Timeout: off")); + else + lv_snprintf(buf, sizeof(buf), _("Timeout: %ds"), timeout); + lv_label_set_text(objects.settings_screen_timeout_label, buf); + lv_obj_clear_flag(objects.settings_screen_timeout_panel, LV_OBJ_FLAG_HIDDEN); + lv_slider_set_value(objects.screen_timeout_slider, timeout, LV_ANIM_OFF); + lv_group_focus_obj(objects.screen_timeout_slider); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eScreenTimeout; + } +} + +void TFTView_480x222::ui_event_screen_lock_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + char buf[10]; + lv_snprintf(buf, 7, "%06d", THIS->db.uiConfig.pin_code); + lv_textarea_set_text(objects.settings_screen_lock_password_textarea, buf); + if (THIS->db.uiConfig.screen_lock) { + lv_obj_add_state(objects.settings_screen_lock_switch, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(objects.settings_screen_lock_switch, LV_STATE_CHECKED); + } + if (THIS->db.uiConfig.settings_lock) { + lv_obj_add_state(objects.settings_settings_lock_switch, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(objects.settings_settings_lock_switch, LV_STATE_CHECKED); + } + + lv_obj_clear_flag(objects.settings_screen_lock_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_screen_lock_switch); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eScreenLock; + } +} + +void TFTView_480x222::ui_event_input_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + std::vector ptr_events = THIS->inputdriver->getPointerDevices(); + std::string ptr_dropdown = _("none"); + for (std::string &s : ptr_events) { + ptr_dropdown += '\n' + s; + } + lv_dropdown_set_options(objects.settings_mouse_input_dropdown, ptr_dropdown.c_str()); + std::string current_ptr = THIS->inputdriver->getCurrentPointerDevice(); + uint32_t ptrOption = lv_dropdown_get_option_index(objects.settings_mouse_input_dropdown, current_ptr.c_str()); + lv_dropdown_set_selected(objects.settings_mouse_input_dropdown, ptrOption); + + std::vector kbd_events = THIS->inputdriver->getKeyboardDevices(); + std::string kbd_dropdown = _("none"); + for (std::string &s : kbd_events) { + kbd_dropdown += '\n' + s; + } + lv_dropdown_set_options(objects.settings_keyboard_input_dropdown, kbd_dropdown.c_str()); + std::string current_kbd = THIS->inputdriver->getCurrentKeyboardDevice(); + uint32_t kbdOption = lv_dropdown_get_option_index(objects.settings_keyboard_input_dropdown, current_kbd.c_str()); + lv_dropdown_set_selected(objects.settings_keyboard_input_dropdown, kbdOption); + + lv_dropdown_get_selected_str(objects.settings_keyboard_input_dropdown, THIS->old_val1_scratch, sizeof(old_val1_scratch)); + lv_dropdown_get_selected_str(objects.settings_mouse_input_dropdown, THIS->old_val2_scratch, sizeof(old_val2_scratch)); + + lv_obj_clear_flag(objects.settings_input_control_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_mouse_input_dropdown); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eInputControl; + } +} + +void TFTView_480x222::ui_event_alert_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone && THIS->db.module_config.has_external_notification) { + bool alert_enabled = THIS->db.module_config.external_notification.alert_message_buzzer && + THIS->db.module_config.external_notification.enabled && !THIS->db.silent; + if (alert_enabled) { + lv_obj_add_state(objects.settings_alert_buzzer_switch, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(objects.settings_alert_buzzer_switch, LV_STATE_CHECKED); + } + // populate dropdown + if (lv_dropdown_get_option_count(objects.settings_ringtone_dropdown) <= 1) { + for (int i = 2; i < numRingtones; i++) { + lv_dropdown_add_option(objects.settings_ringtone_dropdown, ringtone[i].name, i); + } + } + + lv_dropdown_set_selected(objects.settings_ringtone_dropdown, THIS->db.uiConfig.ring_tone_id - 1); + lv_obj_clear_flag(objects.settings_alert_buzzer_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_alert_buzzer_switch); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eAlertBuzzer; + } +} + +// backup & restore +void TFTView_480x222::ui_event_backup_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + lv_obj_clear_flag(objects.settings_backup_restore_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_backup_restore_dropdown); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eBackupRestore; + } +} + +// configuration reset +void TFTView_480x222::ui_event_reset_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + lv_obj_clear_flag(objects.settings_reset_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_reset_dropdown); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eReset; + } +} + +// reboot / shutdown +void TFTView_480x222::ui_event_reboot_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eNone) { + lv_obj_remove_flag(objects.boot_logo_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.bluetooth_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.boot_logo_arc, LV_OBJ_FLAG_HIDDEN); + lv_screen_load_anim(objects.boot_screen, LV_SCR_LOAD_ANIM_FADE_IN, 1000, 0, false); + lv_obj_clear_flag(objects.reboot_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.cancel_reboot_button); + THIS->disablePanel(objects.controller_panel); + THIS->disablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eReboot; + } +} + +void TFTView_480x222::ui_event_device_reboot_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + THIS->controller->requestReboot(5, THIS->ownNode); + lv_screen_load_anim(objects.blank_screen, LV_SCR_LOAD_ANIM_FADE_OUT, 4000, 1000, false); + lv_obj_add_flag(objects.reboot_panel, LV_OBJ_FLAG_HIDDEN); + if (THIS->controller->isStandalone()) { + lv_timer_create(timer_event_reboot, 4000, NULL); + } + } +} + +void TFTView_480x222::ui_event_device_progmode_button(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + + meshtastic_Config_NetworkConfig &network = THIS->db.config.network; + if (network.wifi_enabled) { + network.wifi_enabled = false; + THIS->controller->sendConfig(meshtastic_Config_NetworkConfig{network}); + } + meshtastic_Config_BluetoothConfig &bluetooth = THIS->db.config.bluetooth; + bluetooth.mode = meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN; + bluetooth.fixed_pin = random(100000, 999999); + bluetooth.enabled = true; + THIS->controller->sendConfig(meshtastic_Config_BluetoothConfig{bluetooth}, THIS->ownNode); + lv_screen_load_anim(objects.blank_screen, LV_SCR_LOAD_ANIM_FADE_OUT, 4000, 1000, false); + lv_obj_add_flag(objects.reboot_panel, LV_OBJ_FLAG_HIDDEN); + } else if (event_code == LV_EVENT_LONG_PRESSED) { + if (!THIS->controller->isStandalone()) { +#if defined(HAS_SCREEN) && HAS_SCREEN == 1 + ignoreClicked = true; + // open dialog + lv_obj_remove_flag(objects.settings_reboot_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_reboot_panel); + THIS->activeSettings = eDisplayMode; +#endif + } + } +} + +void TFTView_480x222::ui_event_device_shutdown_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + THIS->controller->requestShutdown(5, THIS->ownNode); + lv_screen_load_anim(objects.blank_screen, LV_SCR_LOAD_ANIM_FADE_OUT, 4000, 1000, false); + lv_obj_add_flag(objects.reboot_panel, LV_OBJ_FLAG_HIDDEN); + if (THIS->controller->isStandalone()) { + lv_timer_create(timer_event_shutdown, 4000, NULL); + } + } +} + +void TFTView_480x222::ui_event_device_cancel_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + lv_obj_add_flag(objects.reboot_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.settings_reboot_panel, LV_OBJ_FLAG_HIDDEN); + THIS->enablePanel(objects.controller_panel); + THIS->enablePanel(objects.tab_page_basic_settings); + lv_group_focus_obj(objects.basic_settings_reboot_button); + THIS->activeSettings = eNone; + } +} + +void TFTView_480x222::ui_event_modify_channel(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && THIS->activeSettings == eChannel) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + uint32_t btn_id = (unsigned long)e->user_data; + int8_t ch = (signed long)THIS->ch_label[btn_id]->user_data; + if (ch != -1) { + meshtastic_ChannelSettings_psk_t &psk = THIS->channel_scratch[ch].settings.psk; + std::string base64 = THIS->pskToBase64(psk.bytes, psk.size); + lv_textarea_set_text(objects.settings_modify_channel_psk_textarea, base64.c_str()); + lv_textarea_set_text(objects.settings_modify_channel_name_textarea, THIS->channel_scratch[ch].settings.name); + objects.settings_modify_channel_name_textarea->user_data = (void *)btn_id; + } else { + for (int i = 0; i < c_max_channels; i++) { + if (THIS->channel_scratch[i].role == meshtastic_Channel_Role_DISABLED) { + // the first created channel is PRIMARY + bool found = false; + for (int j = 0; j < c_max_channels; j++) { + if (THIS->channel_scratch[j].role == meshtastic_Channel_Role_PRIMARY) { + found = true; + break; + } + } + if (!found) { + THIS->channel_scratch[i].role = meshtastic_Channel_Role_PRIMARY; + if (i == 0) { + btn_id = 0; // place on top + } else { + // FIXME: swap ids as in long press + ILOG_ERROR("node does not have primary channel!"); + } + } else + THIS->channel_scratch[i].role = meshtastic_Channel_Role_SECONDARY; + + lv_textarea_set_text(objects.settings_modify_channel_psk_textarea, ""); + lv_textarea_set_text(objects.settings_modify_channel_name_textarea, ""); + THIS->ch_label[btn_id]->user_data = (void *)i; + objects.settings_modify_channel_name_textarea->user_data = (void *)btn_id; + break; + } + } + } + + THIS->disablePanel(objects.settings_channel_panel); + lv_obj_clear_flag(objects.settings_modify_channel_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_modify_channel_name_textarea); + THIS->activeSettings = eModifyChannel; + } +#if 0 // TODO: simple swap not allowed: primary channel must be id 0 + else if (event_code == LV_EVENT_LONG_PRESSED && THIS->activeSettings == eChannel) { + ignoreClicked = true; + // make channel primary on long press; swap with current primary (role, id and name) + uint8_t btn_id = (uint8_t)(unsigned long)e->user_data; + int8_t ch = (signed long)THIS->ch_label[btn_id]->user_data; + if (btn_id != 0 && ch != -1) { + int32_t primary_id = (signed long)THIS->ch_label[0]->user_data; + THIS->channel_scratch[primary_id].role = meshtastic_Channel_Role_SECONDARY; + THIS->channel_scratch[ch].role = meshtastic_Channel_Role_PRIMARY; + THIS->ch_label[0]->user_data = (void *)(uint32_t)ch; + THIS->ch_label[btn_id]->user_data = (void *)primary_id; + lv_label_set_text(THIS->ch_label[0], THIS->channel_scratch[ch].settings.name); + lv_label_set_text(THIS->ch_label[btn_id], THIS->channel_scratch[primary_id].settings.name); + } + } +#endif +} + +void TFTView_480x222::ui_event_generate_psk(lv_event_t *e) +{ + std::string base64 = lv_textarea_get_text(objects.settings_modify_channel_psk_textarea); + if (base64.size() == 0 || THIS->qr) { + meshtastic_ChannelSettings_psk_t psk{.size = 32}; + std::mt19937 generator(millis() + psk.bytes[7]); // Mersenne Twister number generator + for (int i = 0; i < 8; i++) { + int r = generator(); + memcpy(&psk.bytes[i * 4], &r, 4); + } + base64 = THIS->pskToBase64(psk.bytes, psk.size); + lv_textarea_set_text(objects.settings_modify_channel_psk_textarea, base64.c_str()); + } + + std::string base64Https = base64; + for (char &c : base64Https) { + if (c == '+') + c = '-'; + else if (c == '/') + c = '_'; + else if (c == '=') + c = '\0'; // remove paddings at the end of the url + } + std::string qr = "https://meshtastic.org/e/#" + base64Https; + lv_obj_remove_flag(objects.settings_modify_channel_qr_panel, LV_OBJ_FLAG_HIDDEN); + THIS->qr = THIS->showQrCode(objects.settings_modify_channel_qr_panel, qr.c_str()); + lv_obj_add_state(objects.keyboard_button_3, LV_STATE_DISABLED); + lv_obj_add_state(objects.keyboard_button_4, LV_STATE_DISABLED); +} + +void TFTView_480x222::ui_event_qr_code(lv_event_t *e) +{ + lv_obj_remove_state(objects.keyboard_button_3, LV_STATE_DISABLED); + lv_obj_remove_state(objects.keyboard_button_4, LV_STATE_DISABLED); + lv_obj_add_flag(objects.settings_modify_channel_qr_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_delete(THIS->qr); + THIS->qr = nullptr; +} + +void TFTView_480x222::ui_event_delete_channel(lv_event_t *e) +{ + lv_textarea_set_text(objects.settings_modify_channel_psk_textarea, ""); + lv_textarea_set_text(objects.settings_modify_channel_name_textarea, ""); +} + +void TFTView_480x222::ui_event_calibration_screen_loaded(lv_event_t *e) +{ + uint16_t *parameters = (uint16_t *)THIS->db.uiConfig.calibration_data.bytes; + memset(parameters, 0, 16); // clear all calibration data + bool done = THIS->displaydriver->calibrate(parameters); + THIS->db.uiConfig.calibration_data.size = 16; + char buf[32]; + lv_snprintf(buf, sizeof(buf), _("Screen Calibration: %s"), done ? _("done") : _("default")); + lv_label_set_text(objects.basic_settings_calibration_label, buf); + lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_FADE_ON, 200, 0, false); + THIS->controller->storeUIConfig(THIS->db.uiConfig); +} + +void TFTView_480x222::ui_event_pin_screen_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED && lv_scr_act() == objects.lock_screen) { + static const char *hidden[7] = {"o o o o o o", "* o o o o o", "* * o o o o", "* * * o o o", + "* * * * o o", "* * * * * o", "* * * * * *"}; + static char pinEntered[7]{}; + lv_obj_t *obj = (lv_obj_t *)lv_event_get_target(e); + uint32_t id = lv_buttonmatrix_get_selected_button(obj); + const char *key = lv_buttonmatrix_get_button_text(obj, id); + switch (*key) { + case 'X': { + pinKeys = 0; + lv_label_set_text(objects.lock_screen_digits_label, hidden[pinKeys]); + if (!screenLocked) { + lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_FADE_IN, 100, 0, false); + } else { + // TODO: init screen saver + } + break; + } + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': { + if (pinKeys < 6) { + pinEntered[pinKeys++] = *key; + lv_label_set_text(objects.lock_screen_digits_label, hidden[pinKeys]); + + char buf[10]; + lv_snprintf(buf, 7, "%06d", THIS->db.uiConfig.pin_code); + if (pinKeys == 6 && strcmp(pinEntered, buf) == 0) { + // unlock screen + pinKeys = 0; + screenLocked = false; + lv_obj_clear_flag(objects.tab_page_basic_settings, LV_OBJ_FLAG_HIDDEN); + lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_FADE_IN, 100, 0, false); + lv_label_set_text(objects.lock_screen_digits_label, hidden[pinKeys]); + } + } + break; + } + case 'D': { + if (pinKeys > 0) { + pinEntered[--pinKeys] = '\0'; + lv_label_set_text(objects.lock_screen_digits_label, hidden[pinKeys]); + } + break; + } + default: + break; + } + } +} + +void TFTView_480x222::ui_event_backup_restore_radio_button(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + lv_obj_remove_state(objects.settings_backup_checkbox, LV_STATE_CHECKED); + lv_obj_remove_state(objects.settings_restore_checkbox, LV_STATE_CHECKED); + lv_obj_add_state(lv_event_get_target_obj(e), LV_STATE_CHECKED); + } +} + +void TFTView_480x222::ui_event_zoomSlider(lv_event_t *e) +{ + THIS->map->setZoom(lv_slider_get_value(objects.zoom_slider)); + THIS->updateLocationMap(THIS->map->getObjectsOnMap()); +} + +void TFTView_480x222::ui_event_zoomIn(lv_event_t *e) +{ + THIS->map->setZoom(MapTileSettings::getZoomLevel() + 1); + THIS->updateLocationMap(THIS->map->getObjectsOnMap()); +} + +void TFTView_480x222::ui_event_zoomOut(lv_event_t *e) +{ + THIS->map->setZoom(MapTileSettings::getZoomLevel() - 1); + THIS->updateLocationMap(THIS->map->getObjectsOnMap()); +} + +void TFTView_480x222::ui_event_lockGps(lv_event_t *e) +{ + bool gpsLocked = lv_obj_has_state(objects.gps_lock_button, LV_STATE_CHECKED); + THIS->map->setLocked(gpsLocked); + THIS->db.uiConfig.map_data.follow_gps = gpsLocked; + THIS->controller->storeUIConfig(THIS->db.uiConfig); +} + +void TFTView_480x222::ui_event_mapBrightnessSlider(lv_event_t *e) +{ + uint32_t br = lv_slider_get_value(objects.map_brightness_slider); + lv_obj_set_style_bg_color(objects.map_panel, lv_color_make(br, br, br), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(objects.raw_map_panel, lv_color_make(br, br, br), LV_PART_MAIN | LV_STATE_DEFAULT); +} + +void TFTView_480x222::ui_event_mapContrastSlider(lv_event_t *e) +{ + uint32_t ct = lv_slider_get_value(objects.map_contrast_slider); + lv_obj_set_style_opa(objects.raw_map_panel, ct, LV_PART_MAIN | LV_STATE_DEFAULT); +} + +void TFTView_480x222::ui_event_map_style_dropdown(lv_event_t *e) +{ + lv_dropdown_get_selected_str(objects.map_style_dropdown, THIS->db.uiConfig.map_data.style, + sizeof(THIS->db.uiConfig.map_data.style)); + MapTileSettings::setTileStyle(THIS->db.uiConfig.map_data.style); + THIS->controller->storeUIConfig(THIS->db.uiConfig); + lv_obj_add_flag(objects.map_osd_panel, LV_OBJ_FLAG_HIDDEN); + THIS->map->forceRedraw(); +} + +void TFTView_480x222::ui_event_mapNodeButton(lv_event_t *e) +{ + // navigate to node in node list + uint32_t nodeNum = (unsigned long)e->user_data; + ILOG_DEBUG("map node %08x", nodeNum); + lv_obj_t *panel = THIS->nodes[nodeNum]; + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + lv_obj_scroll_to_view(panel, LV_ANIM_ON); + if (panel != currentPanel) + ui_event_NodeButton(e); +} + +void TFTView_480x222::ui_event_chatNodeButton(lv_event_t *e) +{ + uint32_t nodeNum = (unsigned long)e->user_data; + auto it = THIS->nodes.find(nodeNum); + if (it != THIS->nodes.end()) { + lv_obj_t *panel = it->second; + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + lv_obj_scroll_to_view(panel, LV_ANIM_ON); + if (panel != currentPanel) + ui_event_NodeButton(e); + } +} + +void TFTView_480x222::ui_event_positionButton(lv_event_t *e) +{ + // navigate to position in map + lv_obj_t *p = (lv_obj_t *)e->user_data; + int32_t lat = (long)p->LV_OBJ_IDX(node_pos1_idx)->user_data; + int32_t lon = (long)p->LV_OBJ_IDX(node_pos2_idx)->user_data; + if (lat && lon) { + THIS->ui_set_active(objects.map_button, objects.map_panel, objects.top_map_panel); + if (!THIS->map) { + THIS->loadMap(); + } + THIS->map->setScrolledPosition(lat * 1e-7, lon * 1e-7); + } +} + +void TFTView_480x222::ui_screen_event_cb(lv_event_t *e) +{ + if (THIS->activePanel == objects.map_panel) { + lv_dir_t dir = lv_indev_get_gesture_dir(lv_indev_active()); + switch (dir) { + case LV_DIR_LEFT: + e->user_data = (void *)6; + break; + case LV_DIR_RIGHT: + e->user_data = (void *)4; + break; + case LV_DIR_TOP: + e->user_data = (void *)2; + break; + case LV_DIR_BOTTOM: + e->user_data = (void *)8; + break; + default: + break; + } + ILOG_DEBUG("gesture: %d", (uint16_t)dir); + THIS->ui_event_arrow(e); + } +} + +void TFTView_480x222::ui_event_arrow(lv_event_t *e) +{ + if (THIS->map && THIS->map->redrawComplete()) { + uint16_t deltaX = 0; + uint16_t deltaY = 0; + ScrollDirection direction = (ScrollDirection)(unsigned long)e->user_data; + switch (direction) { + case scrollDownLeft: + deltaX = 1; + deltaY = -1; + break; + case scrollDown: + deltaX = 0; + deltaY = -1; + break; + case scrollDownRight: + deltaX = -1; + deltaY = -1; + break; + case scrollLeft: + deltaX = 1; + deltaY = 0; + break; + case scrollRight: + deltaX = -1; + deltaY = 0; + break; + case scrollUpLeft: + deltaX = 1; + deltaY = 1; + break; + case scrollUp: + deltaX = 0; + deltaY = 1; + break; + case scrollUpRight: + deltaX = -1; + deltaY = 1; + break; + default: + break; + }; + if (!THIS->map->scroll(deltaX, deltaY)) + THIS->map->forceRedraw(); + } + THIS->updateLocationMap(THIS->map->getObjectsOnMap()); +} + +void TFTView_480x222::ui_event_navHome(lv_event_t *e) +{ + static bool ignoreClicked = false; + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + if (ignoreClicked) { // prevent long press to enter this setting + ignoreClicked = false; + return; + } + THIS->map->moveHome(); + } else if (event_code == LV_EVENT_LONG_PRESSED) { + ignoreClicked = true; + float lat, lon; + THIS->map->setHomePosition(); + THIS->map->getHomeLocation(lat, lon); + + int32_t ilat = lat * 1e7f; + int32_t ilon = lon * 1e7f; + THIS->db.uiConfig.has_map_data = true; + THIS->db.uiConfig.map_data.has_home = true; + THIS->db.uiConfig.map_data.home.latitude = ilat; + THIS->db.uiConfig.map_data.home.longitude = ilon; + THIS->db.uiConfig.map_data.home.zoom = MapTileSettings::getZoomLevel(); + THIS->controller->storeUIConfig(THIS->db.uiConfig); + + meshtastic_Config_PositionConfig &position = THIS->db.config.position; + if (position.fixed_position) { + THIS->updatePosition(THIS->ownNode, ilat, ilon, 0, 0, 0); + if (position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) { + // grey out text to indicate it's a fixed position vs. actual GPS position + Themes::recolorText(objects.home_location_label, false); + THIS->controller->sendConfig(meshtastic_Position{.latitude_i = ilat, + .longitude_i = ilon, + .time = uint32_t(VALID_TIME(THIS->actTime) ? THIS->actTime : 0), + .location_source = meshtastic_Position_LocSource_LOC_MANUAL}); + } + } + } +} + +void TFTView_480x222::loadMap(void) +{ + if (!map) { +#if LV_USE_FS_ARDUINO_SD + map = new MapPanel(objects.raw_map_panel); +#elif defined(HAS_SD_MMC) + map = new MapPanel(objects.raw_map_panel, new SDCardService()); +#elif defined(HAS_SDCARD) + map = new MapPanel(objects.raw_map_panel, new SdFatService()); +#elif defined(ARCH_PORTDUINO) + map = new MapPanel(objects.raw_map_panel, new SDCardService()); // TODO: LinuxFileSystemService +#else + map = new MapPanel(objects.raw_map_panel); +#endif + map->setHomeLocationImage(objects.home_location_image); + lv_obj_add_flag(objects.home_location_image, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(objects.home_location_image, ui_event_mapNodeButton, LV_EVENT_CLICKED, (void *)ownNode); + + // center map to GPS > home > other nodes > default location + if (db.config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) { + map->setGpsPositionImage(objects.gps_position_image); + lv_obj_clear_flag(objects.gps_position_image, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(objects.gps_position_image, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.gps_lock_button, LV_OBJ_FLAG_HIDDEN); + } + if (hasPosition) { + if (db.uiConfig.map_data.has_home) { + map->setHomeLocation(db.uiConfig.map_data.home.latitude * 1e-7, db.uiConfig.map_data.home.longitude * 1e-7); + map->setZoom(db.uiConfig.map_data.home.zoom); + } else { + map->setHomeLocation(myLatitude * 1e-7, myLongitude * 1e-7); + map->setZoom(13); + } + map->setGpsPosition(myLatitude * 1e-7, myLongitude * 1e-7); + } else if (db.uiConfig.map_data.has_home) { + map->setHomeLocation(db.uiConfig.map_data.home.latitude * 1e-7, db.uiConfig.map_data.home.longitude * 1e-7); + map->setZoom(db.uiConfig.map_data.home.zoom); + } else if (nodeObjects.size() >= 1) { + // no gps, no saved position then center the home location among other available nodes + std::vector sortedLat; + std::vector sortedLon; + sortedLat.reserve(nodeObjects.size()); + sortedLon.reserve(nodeObjects.size()); + for (auto it : nodeObjects) { + lv_obj_t *p = nodes[it.first]; + int32_t lat = (long)p->LV_OBJ_IDX(node_pos1_idx)->user_data; + int32_t lon = (long)p->LV_OBJ_IDX(node_pos2_idx)->user_data; + if (lat && lon) { + sortedLat.push_back(lat); + sortedLon.push_back(lon); + } + } + std::sort(sortedLat.begin(), sortedLat.end()); + std::sort(sortedLon.begin(), sortedLon.end()); + int64_t latcenter = 0; + int64_t loncenter = 0; + int32_t count = 0; + // select just the closest 60% of nodes, ignore the rest + int pp = 100 / 20; + for (int i = sortedLat.size() / pp; i < pp * sortedLat.size() / pp; i++) { + latcenter += sortedLat[i]; + loncenter += sortedLon[i]; + count++; + } + latcenter /= count; + loncenter /= count; + map->setHomeLocation(latcenter * 1e-7, loncenter * 1e-7); + + // calculate optimal zoom factor to fit in all nodes of this range + lv_obj_update_layout(objects.raw_map_panel); + float rangeDeg = 1e-7 * (sortedLon[(pp - 1) * sortedLon.size() / pp] - sortedLon[sortedLon.size() / pp]); + float distanceKm = abs(rangeDeg * 111.32 * cos(1e-7 * sortedLat[sortedLat.size() / 2])); + uint32_t zoom = sqrt(156.543034f / distanceKm * abs(cos(1e-7 * sortedLat[sortedLat.size() / 2])) * 256) + 1; + map->setZoom(zoom); + } else { + // use default location @theBigBentern + map->setZoom(3); + } + + if (db.uiConfig.map_data.follow_gps) { + lv_obj_set_state(objects.gps_lock_button, LV_STATE_CHECKED, true); + map->setLocked(true); + } + + // finally add all node images to the map + if (!nodeObjects.empty()) { + for (auto it : nodeObjects) { + lv_obj_t *p = nodes[it.first]; + float lat = 1e-7 * (long)p->LV_OBJ_IDX(node_pos1_idx)->user_data; + float lon = 1e-7 * (long)p->LV_OBJ_IDX(node_pos2_idx)->user_data; + map->add(it.first, lat, lon, drawObjectCB); + lv_obj_add_flag(it.second, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(it.second, ui_event_mapNodeButton, LV_EVENT_CLICKED, (void *)it.first); + } + } + updateLocationMap(map->getObjectsOnMap()); + } + + if (sdCard) { + if (!sdCard->isUpdated()) { + map->setNoTileImage(&img_no_tile_image); + std::set mapStyles = sdCard->loadMapStyles(MapTileSettings::getPrefix()); + if (mapStyles.find("/map") != mapStyles.end()) { + // no styles found, but the /map directory, so use it + MapTileSettings::setPrefix("/map"); + MapTileSettings::setTileStyle(""); + lv_obj_add_flag(objects.map_style_dropdown, LV_OBJ_FLAG_HIDDEN); + } else if (!mapStyles.empty()) { + // populate dropdown + uint16_t pos = 0; + bool savedStyleOK = false; + lv_dropdown_set_options(objects.map_style_dropdown, ""); + for (auto it : mapStyles) { + lv_dropdown_add_option(objects.map_style_dropdown, it.c_str(), pos); + if (it == db.uiConfig.map_data.style) { + lv_dropdown_set_selected(objects.map_style_dropdown, pos); + MapTileSettings::setTileStyle(db.uiConfig.map_data.style); + savedStyleOK = true; + } + pos++; + } + if (!savedStyleOK) { + // no such style on SD, pick first one we found + char style[20]; + lv_dropdown_set_selected(objects.map_style_dropdown, 0); + lv_dropdown_get_selected_str(objects.map_style_dropdown, style, sizeof(style)); + MapTileSettings::setTileStyle(style); + } + MapTileSettings::setPrefix("/maps"); + } else { + messageAlert(_("No map tiles found on SDCard!"), true); + map->setNoTileImage(&img_no_tile_image); + } + map->forceRedraw(); + } + } else { + lv_dropdown_set_options(objects.map_style_dropdown, ""); + } + + lv_obj_clear_flag(objects.map_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(objects.raw_map_panel, LV_OBJ_FLAG_HIDDEN); +} + +void TFTView_480x222::updateLocationMap(uint32_t num) +{ + lv_label_set_text_fmt(objects.top_map_label, _("Locations Map (%d/%d)"), num, nodeCount); +} + +/** + * add node location image for display on map + */ +void TFTView_480x222::addOrUpdateMap(uint32_t nodeNum, int32_t lat, int32_t lon) +{ + auto it = nodeObjects.find(nodeNum); + if (it == nodeObjects.end()) { + uint32_t bgColor, fgColor; + std::tie(bgColor, fgColor) = nodeColor(nodeNum); + lv_obj_t *img = lv_image_create(objects.raw_map_panel); + lv_obj_set_size(img, 40, 35); + lv_img_set_src(img, &img_circle_image); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_TOP_MID); + lv_obj_set_style_opa(img, 180, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(img, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(img, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(img, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(img, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *lbl = lv_label_create(img); + lv_obj_set_pos(lbl, 0, 0); + lv_obj_set_size(lbl, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_text_color(lbl, lv_color_black(), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_align(lbl, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_align(lbl, LV_ALIGN_BOTTOM_MID, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *p = nodes[nodeNum]; + lv_label_set_text_fmt(lbl, "%s", lv_label_get_text(p->LV_OBJ_IDX(node_lbs_idx))); + + // position label callback + lv_obj_add_flag(p->LV_OBJ_IDX(node_pos1_idx), LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(p->LV_OBJ_IDX(node_pos1_idx), ui_event_positionButton, LV_EVENT_CLICKED, (void *)p); + + nodeObjects[nodeNum] = img; + if (map) { + map->add(nodeNum, lat * 1e-7, lon * 1e-7, drawObjectCB); + lv_obj_add_flag(img, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(img, ui_event_mapNodeButton, LV_EVENT_CLICKED, (void *)nodeNum); + updateLocationMap(map->getObjectsOnMap()); + } + } else { + if (map) { + map->update(it->first, lat * 1e-7, lon * 1e-7); + } + } +} + +void TFTView_480x222::removeFromMap(uint32_t nodeNum) +{ + auto it = nodeObjects.find(nodeNum); + if (it == nodeObjects.end()) + return; + + lv_obj_t *img = it->second; + if (map) { + map->remove(it->first); + updateLocationMap(map->getObjectsOnMap()); + } + nodeObjects.erase(nodeNum); + lv_obj_remove_event_cb(img, ui_event_mapNodeButton); + lv_obj_delete(img); +} + +void TFTView_480x222::ui_event_mesh_detector(lv_event_t *e) +{ + THIS->ui_set_active(objects.settings_button, objects.mesh_detector_panel, objects.top_mesh_detector_panel); +} + +void TFTView_480x222::ui_event_mesh_detector_start(_lv_event_t *e) +{ + lv_obj_add_flag(objects.detector_contact_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.detector_heard_label, LV_OBJ_FLAG_HIDDEN); + if (!THIS->detectorRunning) { + lv_label_set_text(objects.detector_start_label, _("Stop")); + + // create radar animation + lv_anim_t &a = THIS->radar; + lv_anim_init(&a); + lv_anim_set_var(&a, objects.radar_beam); + lv_anim_set_values(&a, 0, 3600); + lv_anim_set_repeat_count(&a, 1800); + lv_anim_set_duration(&a, 7200); + lv_anim_set_exec_cb(&a, ui_anim_radar_cb); + lv_anim_start(&a); + lv_obj_clear_flag(objects.detector_radar_panel, LV_OBJ_FLAG_HIDDEN); + } else { + lv_label_set_text(objects.detector_start_label, _("Start")); + lv_anim_del(&objects.radar_beam, ui_anim_radar_cb); + lv_obj_add_flag(objects.detector_radar_panel, LV_OBJ_FLAG_HIDDEN); + } + THIS->detectorRunning = !THIS->detectorRunning; + THIS->controller->sendPing(); +} + +void TFTView_480x222::ui_event_signal_scanner(lv_event_t *e) +{ + if (currentPanel) { + THIS->setNodeImage(currentNode, (MeshtasticView::eRole)(unsigned long)currentPanel->LV_OBJ_IDX(node_img_idx)->user_data, + false, objects.signal_scanner_node_image); + const char *lbs = lv_label_get_text(currentPanel->LV_OBJ_IDX(node_lbs_idx)); + lv_label_set_text(objects.signal_scanner_node_button_label, lbs); + lv_obj_clear_state(objects.signal_scanner_start_button, LV_STATE_DISABLED); + } else { + lv_label_set_text(objects.signal_scanner_node_button_label, _("choose\nnode")); + lv_obj_add_state(objects.signal_scanner_start_button, LV_STATE_DISABLED); + } + lv_label_set_text(objects.signal_scanner_start_label, _("Start")); + THIS->ui_set_active(objects.settings_button, objects.signal_scanner_panel, objects.top_signal_scanner_panel); +} + +void TFTView_480x222::ui_event_signal_scanner_node(lv_event_t *e) +{ + THIS->chooseNodeSignalScanner = true; + THIS->selectedHops = lv_dropdown_get_selected(objects.nodes_filter_hops_dropdown); + lv_dropdown_set_selected(objects.nodes_filter_hops_dropdown, 7); // 0 hops away + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + THIS->updateNodesFiltered(true); + THIS->updateNodesStatus(); +} + +void TFTView_480x222::ui_event_signal_scanner_start(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (currentNode) { + static bool ignoreClicked = false; + if (event_code == LV_EVENT_CLICKED) { + if (ignoreClicked) { + ignoreClicked = false; + return; + } + if (spinnerButton) { + lv_label_set_text(objects.signal_scanner_start_label, _("Start")); + lv_obj_delete(spinnerButton); + spinnerButton = nullptr; + THIS->scans = 0; + } else { + THIS->scanSignal(0); + } + } else if (event_code == LV_EVENT_LONG_PRESSED) { + ignoreClicked = true; + lv_obj_t *obj = lv_spinner_create(objects.signal_scanner_panel); + spinnerButton = obj; + spinnerButton->user_data = (void *)objects.signal_scanner_panel; + lv_spinner_set_anim_params(obj, 5000, 300); + lv_obj_set_pos(obj, 0, -50); + lv_obj_set_size(obj, 68, 68); + lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + add_style_spinner_style(obj); + lv_label_set_text(objects.signal_scanner_start_label, "30s"); + THIS->scans = 6 + 1; + } + } +} + +void TFTView_480x222::ui_event_trace_route(lv_event_t *e) +{ + // show still old route setup while processing is ongoing + time_t now; + time(&now); + if (spinnerButton && (now - startTime) < 30) { + THIS->ui_set_active(objects.settings_button, objects.trace_route_panel, objects.top_trace_route_panel); + return; + } + THIS->removeSpinner(); + + // remove old route except first button and spinner panel + ILOG_DEBUG("removing old route: %d %d %d", lv_obj_get_child_cnt(objects.trace_route_panel), + lv_obj_get_child_cnt(objects.route_towards_panel), lv_obj_get_child_cnt(objects.route_back_panel)); + + uint16_t children = lv_obj_get_child_cnt(objects.trace_route_panel) - 1; + while (children > 1) { + if (objects.trace_route_panel->spec_attr->children[children]->class_p == &lv_button_class) { + lv_obj_delete(objects.trace_route_panel->spec_attr->children[children]); + } + children--; + } + + // forward route + children = lv_obj_get_child_cnt(objects.route_towards_panel); + while (children > 0) { + children--; + if (objects.route_towards_panel->spec_attr->children[children]->class_p == &lv_button_class) { + lv_obj_delete(objects.route_towards_panel->spec_attr->children[children]); + } + } + + // backward route + children = lv_obj_get_child_cnt(objects.route_back_panel); + while (children > 0) { + children--; + if (objects.route_back_panel->spec_attr->children[children]->class_p == &lv_button_class) { + lv_obj_delete(objects.route_back_panel->spec_attr->children[children]); + } + } + + lv_obj_clear_flag(objects.start_button_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.hop_routes_panel, LV_OBJ_FLAG_HIDDEN); + + if (currentPanel) { + THIS->setNodeImage(THIS->currentNode, + (MeshtasticView::eRole)(unsigned long)currentPanel->LV_OBJ_IDX(node_img_idx)->user_data, false, + objects.trace_route_to_image); + const char *lbl = lv_label_get_text(currentPanel->LV_OBJ_IDX(node_lbl_idx)); + lv_label_set_text(objects.trace_route_to_button_label, lbl); + lv_obj_clear_state(objects.trace_route_start_button, LV_STATE_DISABLED); + } else { + lv_label_set_text(objects.trace_route_to_button_label, _("choose target node")); + lv_obj_add_state(objects.trace_route_start_button, LV_STATE_DISABLED); + } + lv_label_set_text(objects.trace_route_start_label, _("Start")); + THIS->ui_set_active(objects.settings_button, objects.trace_route_panel, objects.top_trace_route_panel); +} + +void TFTView_480x222::ui_event_trace_route_to(lv_event_t *e) +{ + if (!spinnerButton) { + THIS->chooseNodeTraceRoute = true; + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + } +} + +void TFTView_480x222::ui_event_trace_route_start(lv_event_t *e) +{ + if (!spinnerButton) { + if (currentPanel) { + time(&startTime); + lv_obj_t *obj = lv_spinner_create(objects.start_button_panel); + spinnerButton = obj; + lv_spinner_set_anim_params(obj, 5000, 300); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, 68, 68); + lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + add_style_spinner_style(obj); + lv_label_set_text(objects.trace_route_start_label, "30s"); + + // retrieve nodeNum from current node + // FIXME: remove for loop + for (auto &it : THIS->nodes) { + if (it.second == currentPanel) { + uint32_t requestId; + uint32_t to = it.first; + uint8_t ch = (uint8_t)(unsigned long)currentPanel->user_data; + // trial: hoplimit optimization for direct messages + int8_t hopsAway = (signed long)THIS->nodes[to]->LV_OBJ_IDX(node_sig_idx)->user_data; + if (hopsAway < 0) + hopsAway = 5; + uint8_t hopLimit = (hopsAway < THIS->db.config.lora.hop_limit ? hopsAway + 1 : hopsAway); + requestId = THIS->requests.addRequest(to, ResponseHandler::TraceRouteRequest); + THIS->controller->traceRoute(to, ch, hopLimit, requestId); + break; + } + } + } + } else { + // restart + ui_event_trace_route(e); + } +} + +void TFTView_480x222::ui_event_trace_route_node(lv_event_t *e) +{ + // navigate to node in node list + lv_obj_t *panel = (lv_obj_t *)e->user_data; + THIS->ui_set_active(objects.nodes_button, objects.nodes_panel, objects.top_nodes_panel); + lv_obj_scroll_to_view(panel, LV_ANIM_ON); +} + +void TFTView_480x222::removeSpinner(void) +{ + if (spinnerButton) { + lv_obj_delete(spinnerButton); + spinnerButton = nullptr; + startTime = 0; + } +} + +void TFTView_480x222::ui_event_node_details(lv_event_t *e) +{ + THIS->ui_set_active(objects.settings_button, objects.details_panel, objects.top_nodes_panel); +} + +void TFTView_480x222::ui_event_statistics(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + THIS->ui_set_active(objects.settings_button, objects.tools_statistics_panel, objects.top_statistics_panel); + } else if (event_code == LV_EVENT_LONG_PRESSED) { + // clear statistics table + THIS->updateStatistics(meshtastic_MeshPacket{.from = 0}); + } +} + +void TFTView_480x222::ui_event_packet_log(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + THIS->ui_set_active(objects.settings_button, objects.tools_packet_log_panel, objects.top_packet_log_panel); + THIS->packetLogEnabled = true; + } else if (event_code == LV_EVENT_LONG_PRESSED) { + THIS->packetCounter = 0; + lv_obj_clean(objects.tools_packet_log_panel); + } +} + +void TFTView_480x222::packetDetected(const meshtastic_MeshPacket &p) +{ + uint32_t heard = 0; + if (p.from != ownNode) + heard = p.from; + if (p.to != 0xffffffff && p.to != ownNode) + heard = p.to; + + if (heard) { + if (p.to == ownNode && p.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) { + // we finally sensed a two-way contact to us; stop the detector + detectorRunning = false; + lv_label_set_text(objects.detector_start_label, _("Start")); + lv_anim_del(&objects.radar_beam, ui_anim_radar_cb); + lv_obj_add_flag(objects.detector_radar_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.detector_heard_label, LV_OBJ_FLAG_HIDDEN); + + setNodeImage(p.from, (MeshtasticView::eRole)(unsigned long)nodes[p.from]->LV_OBJ_IDX(node_img_idx)->user_data, false, + objects.detector_contact_image); + const char *lbl = lv_label_get_text(nodes[p.from]->LV_OBJ_IDX(node_lbl_idx)); + + char from[5]; + char *userShort = (char *)&(nodes[p.from]->LV_OBJ_IDX(node_lbs_idx)->user_data); + int pos = 0; + while (pos < 4 && userShort[pos] != 0) { + from[pos] = userShort[pos]; + pos++; + } + from[pos] = '\0'; + + char buf[64]; + lv_snprintf(buf, 64, "%s(%04x)\n%s", from, p.from & 0xffff, lbl); + lv_label_set_text(objects.detector_contact_label, buf); + lv_obj_clear_flag(objects.detector_contact_button, LV_OBJ_FLAG_HIDDEN); + } else { + char buf[20]; + lv_snprintf(buf, 20, _("heard: !%08x"), heard); + lv_label_set_text(objects.detector_heard_label, buf); + lv_obj_clear_flag(objects.detector_heard_label, LV_OBJ_FLAG_HIDDEN); + } + } +} + +void TFTView_480x222::writePacketLog(const meshtastic_MeshPacket &p) +{ + static std::unordered_map name = { + {0, "unknown"}, {1, "text message"}, {2, "remote hardware"}, {3, "position"}, {4, "node info"}, + {5, "routing"}, {6, "admin"}, {7, "text message"}, {8, "waypoint"}, {9, "audio"}, + {10, "sensor"}, {32, "reply"}, {33, "ip tunnel"}, {34, "paxcounter"}, {64, "serial"}, + {65, "store forward"}, {66, "range test"}, {67, "telemetry"}, {68, "ZPS"}, {69, "simulator"}, + {70, "tracert"}, {71, "neighbor info"}, {72, "atax"}, {73, "map report"}, {74, "power stress"}, + {256, "private"}, {257, "atax forwarder"}}; + + // ignore admin packages initiated by us + if (p.from == ownNode && p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) + return; + + // get actual time + char timebuf[16]; + time_t curr_time; +#ifdef ARCH_PORTDUINO + time(&curr_time); +#else + curr_time = actTime; +#endif + tm *curr_tm = localtime(&curr_time); + if (VALID_TIME(curr_time)) { + strftime(timebuf, 16, "%T", curr_tm); + } else { + strcpy(timebuf, "??:??:??"); + } + + // get node name from + char from[5]; + char *userShort = (char *)&(nodes[p.from]->LV_OBJ_IDX(node_lbs_idx)->user_data); + int pos = 0; + while (pos < 4 && userShort[pos] != 0) { + from[pos] = userShort[pos]; + pos++; + } + from[pos] = '\0'; + + char buf[256]; + if (p.to == 0xffffffff) + sprintf(buf, "%s: ch%d %s:%04x->all: %s", timebuf, p.channel, from, p.from & 0xffff, + name[p.decoded.portnum]); // note: this may crash if there's a new portnum not in this map... + else + sprintf(buf, "%s: ch%d %s:%04x->%s%04x: %s", timebuf, p.channel, from, p.from & 0xffff, p.to == ownNode ? "*" : "", + p.to & 0xffff, name[p.decoded.portnum]); + + if (p.decoded.portnum == meshtastic_PortNum_TELEMETRY_APP) { + meshtastic_Telemetry telemetry; + if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_Telemetry_msg, &telemetry)) { + switch (telemetry.which_variant) { + case meshtastic_Telemetry_device_metrics_tag: { + if (p.from == ownNode) + return; // suppress (internal) battery level packets + strcat(buf, " dev"); + break; + } + case meshtastic_Telemetry_environment_metrics_tag: { + strcat(buf, " env"); + break; + } + case meshtastic_Telemetry_air_quality_metrics_tag: { + strcat(buf, " air"); + break; + } + case meshtastic_Telemetry_power_metrics_tag: { + strcat(buf, " pow"); + break; + } + case meshtastic_Telemetry_local_stats_tag: { + strcat(buf, " dev"); // bug in firmware that this is local? + } + default: + break; + } + } + } else if (p.decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) { + // print the recorded route and add from/to manually + strcat(buf, "\n"); + int pos = strlen(buf); + if (p.to == ownNode) { + pos += snprintf(&buf[pos], 16, "%04x", ownNode & 0xffff); + } + + meshtastic_RouteDiscovery route; + if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_RouteDiscovery_msg, &route)) { + for (int i = 0; i < route.route_count; i++) { + uint32_t nodeNum = route.route[i]; + if (nodeNum != UINT32_MAX) { + pos += snprintf(&buf[pos], 16, "->%04x", nodeNum & 0xffff); + } else { + strcat(buf, "->unk"); + pos += 6; + } + } + if (p.to == ownNode) { + pos += snprintf(&buf[pos], 16, "->%04x", p.from & 0xffff); + } + } + } + + if (packetCounter >= PACKET_LOGS_MAX) { + // delete oldest entry + lv_obj_del(objects.tools_packet_log_panel->spec_attr->children[0]); + } else { + packetCounter++; + char top[24]; + sprintf(top, _("Packet Log: %d"), packetCounter); + lv_label_set_text(objects.top_packet_log_label, top); + } + lv_obj_t *pLabel = lv_label_create(objects.tools_packet_log_panel); + lv_obj_set_pos(pLabel, 0, 0); + lv_obj_set_size(pLabel, LV_PCT(100), LV_SIZE_CONTENT); + uint32_t bgColor, fgColor; + std::tie(bgColor, fgColor) = nodeColor(p.from); + lv_obj_set_style_bg_color(pLabel, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(pLabel, lv_color_hex(fgColor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(pLabel, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_text(pLabel, buf); + + // auto-scroll if last item is visible + if (lv_obj_get_scroll_bottom(objects.tools_packet_log_panel) < 20) + lv_obj_scroll_to_view(pLabel, LV_ANIM_OFF); +} + +void TFTView_480x222::updateStatistics(const meshtastic_MeshPacket &p) +{ + struct Stats { + uint32_t id; + uint16_t row; + uint16_t tel; + uint16_t pos; + uint16_t inf; + uint16_t trc; + uint16_t txt; + uint16_t nbr; + uint32_t sum; + + bool operator==(const Stats &rhs) const { return id == rhs.id; } + + Stats &operator+=(const Stats &rhs) + { + this->tel += rhs.tel; + this->pos += rhs.pos; + this->inf += rhs.inf; + this->trc += rhs.trc; + this->txt += rhs.txt; + this->nbr += rhs.nbr; + this->sum += 1; + return *this; + } + + bool operator<(const Stats &rhs) const + { + return sum > rhs.sum; // sort reverse but skip equal values + } + }; + static std::list stats; + + if (p.from == 0) { + // clear table + stats.clear(); + for (int i = 1; i < statisticTableRows; i++) { + for (int j = 0; j < 7; j++) { + lv_table_set_cell_value(objects.statistics_table, i, j, ""); + } + } + return; + } + + // update statistic for node + Stats stat = {p.from}; + switch (p.decoded.portnum) { + case meshtastic_PortNum_TELEMETRY_APP: { + meshtastic_Telemetry telemetry; + if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_Telemetry_msg, &telemetry)) { + if (telemetry.which_variant == meshtastic_Telemetry_device_metrics_tag) { + if (p.from == ownNode) + return; // suppress (internal) battery level packets + } + } + stat.tel++; + break; + } + case meshtastic_PortNum_POSITION_APP: { + stat.pos++; + break; + } + case meshtastic_PortNum_NODEINFO_APP: { + stat.inf++; + break; + } + case meshtastic_PortNum_ROUTING_APP: + case meshtastic_PortNum_TRACEROUTE_APP: { + stat.trc++; + break; + } + case meshtastic_PortNum_TEXT_MESSAGE_APP: + case meshtastic_PortNum_RANGE_TEST_APP: { + stat.txt++; + break; + } + case meshtastic_PortNum_NEIGHBORINFO_APP: { + stat.nbr++; + break; + } + case meshtastic_PortNum_ADMIN_APP: { + // ignore + break; + } + default: + ILOG_WARN("packet portnum in stats unhandled: %d", p.decoded.portnum); + stat.sum++; + return; + } + + std::list::iterator it = std::find(stats.begin(), stats.end(), stat); + if (it == stats.end()) { + stat.row = stats.size(); + stat.sum = 1; + // TODO: stop if memory limit is reached + stats.push_back(stat); + } else { + *it += stat; + } + + stats.sort(); + + // fill packet statistics table + char buf[10]; + int row = 1; + bool move = false; + + for (auto it2 : stats) { + if (it2.id == p.from || move) { + buf[0] = '\0'; + auto it = nodes.find(it2.id); // node may have been removed from nodes, so check if still there + if (it != nodes.end() && it->second) { + char *userData = (char *)&(it->second->LV_OBJ_IDX(node_lbs_idx)->user_data); + if (userData) { + buf[0] = userData[0]; + buf[1] = userData[1]; + buf[2] = userData[2]; + buf[3] = userData[3]; + buf[4] = '\0'; + } + } + + lv_table_set_cell_value(objects.statistics_table, row, 0, buf); + sprintf(buf, "%d", it2.tel); + lv_table_set_cell_value(objects.statistics_table, row, 1, buf); + sprintf(buf, "%d", it2.pos); + lv_table_set_cell_value(objects.statistics_table, row, 2, buf); + sprintf(buf, "%d", it2.inf); + lv_table_set_cell_value(objects.statistics_table, row, 3, buf); + sprintf(buf, "%d", it2.trc); + lv_table_set_cell_value(objects.statistics_table, row, 4, buf); + sprintf(buf, "%d", it2.nbr); + lv_table_set_cell_value(objects.statistics_table, row, 5, buf); + sprintf(buf, "%d", it2.sum); + lv_table_set_cell_value(objects.statistics_table, row, 6, buf); + + if (row != it2.row) { + it2.row = row; + move = true; + } else { + break; + } + } + row++; + if (row >= statisticTableRows) // fill rows till bottom of 320x240 display + break; + } +} + +void TFTView_480x222::ui_event_statistics_table(lv_event_t *e) +{ + lv_draw_task_t *draw_task = lv_event_get_draw_task(e); + lv_draw_dsc_base_t *base_dsc = (lv_draw_dsc_base_t *)lv_draw_task_get_draw_dsc(draw_task); + // if the cells are drawn... + if (base_dsc->part == LV_PART_ITEMS) { + // make the texts in the first cell blueish + lv_draw_fill_dsc_t *fill_draw_dsc = lv_draw_task_get_fill_dsc(draw_task); + if (fill_draw_dsc) { + uint32_t row = base_dsc->id1; + if (row == 0) { + fill_draw_dsc->color = lv_color_mix(lv_palette_main(LV_PALETTE_BLUE), fill_draw_dsc->color, LV_OPA_20); + } + // make every 2nd row grayish + else { + Themes::recolorTableRow(fill_draw_dsc, row % 2 == 0); + } + } + } +} + +void TFTView_480x222::requestSetup(void) +{ + ui_set_active(objects.settings_button, objects.initial_setup_panel, objects.top_setup_panel); + lv_dropdown_set_selected(objects.setup_region_dropdown, 0); + lv_obj_clear_flag(objects.initial_setup_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.setup_region_dropdown); + THIS->disablePanel(objects.controller_panel); + THIS->activeSettings = eSetup; +} + +/** + * update signal strength text and image for home screen + */ +void TFTView_480x222::updateSignalStrength(int32_t rssi, float snr) +{ + // remember time we last heard a node + time(&lastHeard); + + if (rssi != 0 || snr != 0.0) { + char buf[40]; + sprintf(buf, "SNR: %.1f\nRSSI: %" PRId32, snr, rssi); + lv_label_set_text(objects.home_signal_label, buf); + + uint32_t pct = signalStrength2Percent(rssi, snr); + sprintf(buf, "(%d%%)", pct); + lv_label_set_text(objects.home_signal_pct_label, buf); + if (pct > 80) { + lv_obj_set_style_bg_image_src(objects.home_signal_button, &img_home_signal_button_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } else if (pct > 60) { + lv_obj_set_style_bg_image_src(objects.home_signal_button, &img_home_strong_signal_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } else if (pct > 40) { + lv_obj_set_style_bg_image_src(objects.home_signal_button, &img_home_good_signal_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } else if (pct > 20) { + lv_obj_set_style_bg_image_src(objects.home_signal_button, &img_home_fair_signal_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } else if (pct > 1) { + lv_obj_set_style_bg_image_src(objects.home_signal_button, &img_home_weak_signal_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } else { + lv_obj_set_style_bg_image_src(objects.home_signal_button, &img_home_no_signal_image, LV_PART_MAIN | LV_STATE_DEFAULT); + } + } +} + +/** + * Translate proto modem preset enum value to numerical position in dropdown menu + */ +uint32_t TFTView_480x222::preset2val(meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + int32_t val[] = {0, -1, -1, 4, 3, 7, 5, 1, 6, 2}; + + if (preset > (sizeof(val) / sizeof(val[0]) - 1) || val[preset] == -1) { + ILOG_WARN("unknown or deprecated preset value: %d", preset); + return 0; + } + return uint32_t(val[preset]); +} + +/** + * Translate value from dropdown menu to modem preset proto enum + */ +meshtastic_Config_LoRaConfig_ModemPreset TFTView_480x222::val2preset(uint32_t val) +{ + meshtastic_Config_LoRaConfig_ModemPreset preset[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW}; + if (val > (sizeof(preset) / sizeof(preset[0]) - 1)) { + ILOG_ERROR("unknown preset value: %d", val); + return meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + } + return preset[val]; +} + +/** + * Translate proto role enum value to numerical position in dropdown menu + */ +uint32_t TFTView_480x222::role2val(meshtastic_Config_DeviceConfig_Role role) +{ +#ifdef USE_ROUTER_ROLE + int32_t val[] = {0, 1, 2, -1, 3, 4, 5, 6, 7, 8, 9}; +#else + int32_t val[] = {0, 1, -1, -1, -1, 2, 3, 4, 5, 6, 7}; +#endif + if (role > 10 || val[role] == -1) { + ILOG_WARN("unknown role value: %d", role); + return 0; + } + return uint32_t(val[role]); +} + +/** + * Translate value from dropdown menu to role proto enum + */ +meshtastic_Config_DeviceConfig_Role TFTView_480x222::val2role(uint32_t val) +{ + meshtastic_Config_DeviceConfig_Role role[] = {meshtastic_Config_DeviceConfig_Role_CLIENT, + meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE, +#ifdef USE_ROUTER_ROLE + meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_REPEATER, +#endif + meshtastic_Config_DeviceConfig_Role_TRACKER, + meshtastic_Config_DeviceConfig_Role_SENSOR, + meshtastic_Config_DeviceConfig_Role_TAK, + meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN, + meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND, + meshtastic_Config_DeviceConfig_Role_TAK_TRACKER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE}; + if (val > 10) { + ILOG_WARN("unknown role value: %d", val); + return meshtastic_Config_DeviceConfig_Role_CLIENT; + } + return role[val]; +} + +/** + * Translate language proto enum value to (alphabetical) position in dropdown menu + */ +uint32_t TFTView_480x222::language2val(meshtastic_Language lang) +{ + switch (lang) { + case meshtastic_Language_ENGLISH: + return 0; + case meshtastic_Language_FRENCH: + return 7; + case meshtastic_Language_GERMAN: + return 4; + case meshtastic_Language_ITALIAN: + return 8; + case meshtastic_Language_PORTUGUESE: + return 12; + case meshtastic_Language_SPANISH: + return 6; + case meshtastic_Language_SWEDISH: + return 17; + case meshtastic_Language_FINNISH: + return 16; + case meshtastic_Language_POLISH: + return 11; + case meshtastic_Language_TURKISH: + return 18; + case meshtastic_Language_SERBIAN: + return 15; + case meshtastic_Language_RUSSIAN: + return 13; + case meshtastic_Language_DUTCH: + return 9; + case meshtastic_Language_GREEK: + return 5; + case meshtastic_Language_NORWEGIAN: + return 10; + case meshtastic_Language_SLOVENIAN: + return 14; + case meshtastic_Language_UKRAINIAN: + return 19; + case meshtastic_Language_BULGARIAN: + return 1; + case meshtastic_Language_CZECH: + return 2; + case meshtastic_Language_DANISH: + return 3; + case meshtastic_Language_SIMPLIFIED_CHINESE: + return 20; + case meshtastic_Language_TRADITIONAL_CHINESE: + return 21; + default: + ILOG_WARN("unknown language uiconfig: %d", lang); + } + return 0; +} + +/** + * Translate value from dropdown menu to language proto enum + */ +meshtastic_Language TFTView_480x222::val2language(uint32_t val) +{ + switch (val) { + case 0: + return meshtastic_Language_ENGLISH; + case 7: + return meshtastic_Language_FRENCH; + case 4: + return meshtastic_Language_GERMAN; + case 8: + return meshtastic_Language_ITALIAN; + case 12: + return meshtastic_Language_PORTUGUESE; + case 6: + return meshtastic_Language_SPANISH; + case 17: + return meshtastic_Language_SWEDISH; + case 16: + return meshtastic_Language_FINNISH; + case 11: + return meshtastic_Language_POLISH; + case 18: + return meshtastic_Language_TURKISH; + case 15: + return meshtastic_Language_SERBIAN; + case 13: + return meshtastic_Language_RUSSIAN; + case 9: + return meshtastic_Language_DUTCH; + case 5: + return meshtastic_Language_GREEK; + case 10: + return meshtastic_Language_NORWEGIAN; + case 14: + return meshtastic_Language_SLOVENIAN; + case 19: + return meshtastic_Language_UKRAINIAN; + case 1: + return meshtastic_Language_BULGARIAN; + case 2: + return meshtastic_Language_CZECH; + case 3: + return meshtastic_Language_DANISH; + case 20: + return meshtastic_Language_SIMPLIFIED_CHINESE; + case 21: + return meshtastic_Language_TRADITIONAL_CHINESE; + default: + ILOG_WARN("unknown language val: %d", val); + } + return meshtastic_Language_ENGLISH; +} + +/** + * @brief Set lv_i18n language + */ +void TFTView_480x222::setLocale(meshtastic_Language lang) +{ + const char *locale = "en_US.UTF-8"; + switch (lang) { + case meshtastic_Language_ENGLISH: + lv_i18n_set_locale("en"); + break; + case meshtastic_Language_BULGARIAN: + lv_i18n_set_locale("bg"); + locale = "bg_BG.UTF-8"; + break; + case meshtastic_Language_GERMAN: + lv_i18n_set_locale("de"); + locale = "de_DE.UTF-8"; + break; + case meshtastic_Language_SPANISH: + lv_i18n_set_locale("es"); + locale = "es_ES.UTF-8"; + break; + case meshtastic_Language_FRENCH: + lv_i18n_set_locale("fr"); + locale = "fr_FR.UTF-8"; + break; + case meshtastic_Language_ITALIAN: + lv_i18n_set_locale("it"); + locale = "it_IT.UTF-8"; + break; + case meshtastic_Language_PORTUGUESE: + lv_i18n_set_locale("pt"); + locale = "pt_PT.UTF-8"; + break; + case meshtastic_Language_SWEDISH: + lv_i18n_set_locale("se"); + locale = "sv_SE.UTF-8"; + break; + case meshtastic_Language_FINNISH: + lv_i18n_set_locale("fi"); + locale = "fi_FI.UTF-8"; + break; + case meshtastic_Language_POLISH: + lv_i18n_set_locale("pl"); + locale = "pl_PL.UTF-8"; + break; + case meshtastic_Language_TURKISH: + lv_i18n_set_locale("tr"); + locale = "tr_TR.UTF-8"; + break; + case meshtastic_Language_SERBIAN: + lv_i18n_set_locale("sr"); + locale = "sr_RS.UTF-8"; + break; + case meshtastic_Language_DUTCH: + lv_i18n_set_locale("nl"); + locale = "nl_NL.UTF-8"; + break; + case meshtastic_Language_RUSSIAN: + lv_i18n_set_locale("ru"); + locale = "ru_RU.UTF-8"; + break; + case meshtastic_Language_GREEK: + lv_i18n_set_locale("el"); + locale = "el_GR.UTF-8"; + break; + case meshtastic_Language_NORWEGIAN: + lv_i18n_set_locale("no"); + locale = "no_NO.UTF-8"; + break; + case meshtastic_Language_SLOVENIAN: + lv_i18n_set_locale("sl"); + locale = "sl_SI.UTF-8"; + break; + case meshtastic_Language_UKRAINIAN: + lv_i18n_set_locale("uk"); + locale = "uk_UA.UTF-8"; + break; + case meshtastic_Language_CZECH: + lv_i18n_set_locale("cs"); + locale = "cs_CZ.UTF-8"; + break; + case meshtastic_Language_DANISH: + lv_i18n_set_locale("da"); + locale = "da_DK.UTF-8"; + break; + case meshtastic_Language_SIMPLIFIED_CHINESE: + lv_i18n_set_locale("cn"); + locale = "zh_CN.UTF-8"; + break; + case meshtastic_Language_TRADITIONAL_CHINESE: + lv_i18n_set_locale("tw"); + locale = "zh_TW.UTF-8"; + break; + default: + ILOG_WARN("Language %d not implemented", lang); + break; + } + +#if defined(LOCALE_SUPPORT) + std::locale::global(std::locale(locale)); +#else + (void)locale; +#endif +} + +/** + * @brief Set language (using dropdown strings) + */ +void TFTView_480x222::setLanguage(meshtastic_Language lang) +{ + char buf1[20], buf2[40]; + lv_dropdown_set_selected(objects.settings_language_dropdown, language2val(lang)); + lv_dropdown_get_selected_str(objects.settings_language_dropdown, buf1, sizeof(buf1)); + lv_snprintf(buf2, sizeof(buf2), _("Language: %s"), buf1); + lv_label_set_text(objects.basic_settings_language_label, buf2); +} + +/** + * @brief Set timeout + */ +void TFTView_480x222::setTimeout(uint32_t timeout) +{ + char buf[32]; + if (timeout == 0) + lv_snprintf(buf, sizeof(buf), _("Screen Timeout: off")); + else + lv_snprintf(buf, sizeof(buf), _("Screen Timeout: %ds"), timeout); + lv_label_set_text(objects.basic_settings_timeout_label, buf); + THIS->displaydriver->setScreenTimeout(timeout); +} + +/** + * @brief Set brightness + */ +void TFTView_480x222::setBrightness(uint32_t brightness) +{ + char buf[32]; + lv_snprintf(buf, sizeof(buf), _("Screen Brightness: %d%%"), uint16_t(round((brightness * 100) / 255.0))); + lv_label_set_text(objects.basic_settings_brightness_label, buf); + THIS->displaydriver->setBrightness((uint8_t)brightness); +} + +/** + * @brief Set theme to new value + */ +void TFTView_480x222::setTheme(uint32_t value) +{ + char buf1[30], buf2[30]; + lv_dropdown_set_selected(objects.settings_theme_dropdown, value); + lv_dropdown_get_selected_str(objects.settings_theme_dropdown, buf1, sizeof(buf1)); + lv_snprintf(buf2, sizeof(buf2), _("Theme: %s"), buf1); + lv_label_set_text(objects.basic_settings_theme_label, buf2); + + // change theme and redraw UI + Themes::set(Themes::Theme(value)); + updateTheme(); +} + +/** + * @brief Save all data from node options panel + */ +void TFTView_480x222::storeNodeOptions(void) +{ + // store node filter options + meshtastic_NodeFilter &filter = db.uiConfig.node_filter; + db.uiConfig.has_node_filter = true; + filter.unknown_switch = lv_obj_has_state(objects.nodes_filter_unknown_switch, LV_STATE_CHECKED); + filter.offline_switch = lv_obj_has_state(objects.nodes_filter_offline_switch, LV_STATE_CHECKED); + filter.public_key_switch = lv_obj_has_state(objects.nodes_filter_public_key_switch, LV_STATE_CHECKED); + // filter.channel = lv_dropdown_get_selected(objects.nodes_filter_channel_dropdown); + filter.hops_away = lv_dropdown_get_selected(objects.nodes_filter_hops_dropdown); + // filter.mqtt_switch = lv_obj_has_state(objects.nodes_filter_mqtt_switch, LV_STATE_CHECKED); + filter.position_switch = lv_obj_has_state(objects.nodes_filter_position_switch, LV_STATE_CHECKED); + strncpy(filter.node_name, lv_textarea_get_text(objects.nodes_filter_name_area), sizeof(filter.node_name)); + + // store node highlight options + meshtastic_NodeHighlight &highlight = db.uiConfig.node_highlight; + db.uiConfig.has_node_highlight = true; + highlight.chat_switch = lv_obj_has_state(objects.nodes_hl_active_chat_switch, LV_STATE_CHECKED); + highlight.position_switch = lv_obj_has_state(objects.nodes_hl_position_switch, LV_STATE_CHECKED); + highlight.telemetry_switch = lv_obj_has_state(objects.nodes_hl_telemetry_switch, LV_STATE_CHECKED); + highlight.iaq_switch = lv_obj_has_state(objects.nodes_hliaq_switch, LV_STATE_CHECKED); + strncpy(highlight.node_name, lv_textarea_get_text(objects.nodes_hl_name_area), sizeof(highlight.node_name)); + + controller->storeUIConfig(db.uiConfig); +} + +/** + * @brief erase chat and all its resources + */ +void TFTView_480x222::eraseChat(uint32_t channelOrNode) +{ + if (chats.find(channelOrNode) == chats.end()) { + ILOG_WARN("eraseChat: channelOrNode %d not found", channelOrNode); + return; + } + if (channelOrNode < c_max_channels) { + uint8_t ch = (uint8_t)channelOrNode; + if (state == MeshtasticView::eRunning) { + lv_obj_delete_delayed(chats.at(ch), 500); + } else { + lv_obj_del(chats.at(ch)); + } + lv_obj_del(channelGroup.at(ch)); + channelGroup[ch] = nullptr; + chats.erase(ch); + } else { + uint32_t nodeNum = channelOrNode; + if (state == MeshtasticView::eRunning) { + lv_obj_delete_delayed(chats.at(nodeNum), 500); + } else { + lv_obj_delete(chats.at(nodeNum)); + } + lv_obj_del(messages.at(nodeNum)); + messages.erase(nodeNum); + chats.erase(nodeNum); + } +} + +/** + * @brief clears all (persistent) chat messages + */ +void TFTView_480x222::clearChatHistory(void) +{ + for (auto &it : chats) { + lv_obj_delete(it.second); + if (it.first < c_max_channels) { + lv_obj_delete(channelGroup[it.first]); + channelGroup[it.first] = nullptr; + } else { + lv_obj_delete(messages[it.first]); + } + } + chats.clear(); + messages.clear(); + updateActiveChats(); + updateNodesFiltered(true); + controller->removeTextMessages(0, 0, 0); +} + +/** + * @brief User widget OK button handling + * + * @param e + */ +void TFTView_480x222::ui_event_ok(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + switch (THIS->activeSettings) { + case eSetup: { + meshtastic_Config_LoRaConfig_RegionCode region = + (meshtastic_Config_LoRaConfig_RegionCode)(lv_dropdown_get_selected(objects.setup_region_dropdown) + 1); + + uint32_t numChannels = LoRaPresets::getNumChannels(region, THIS->db.config.lora.modem_preset); + // if (numChannels == 0) { + // // region not possible for selected preset, revert + // lv_dropdown_set_selected(objects.settings_region_dropdown, THIS->db.config.lora.region - 1); + // return; + // } + + if (region != THIS->db.config.lora.region) { + char buf1[10], buf2[30]; + lv_dropdown_get_selected_str(objects.setup_region_dropdown, buf1, sizeof(buf1)); + lv_snprintf(buf2, sizeof(buf2), _("Region: %s"), buf1); + lv_label_set_text(objects.basic_settings_region_label, buf2); + + meshtastic_Config_LoRaConfig &lora = THIS->db.config.lora; + uint32_t defaultSlot = lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET ? lora.channel_num : 0; + if (defaultSlot == 0) { + defaultSlot = + LoRaPresets::getDefaultSlot(region, THIS->db.config.lora.modem_preset, THIS->db.channel[0].settings.name); + } + lora.region = region; + lora.channel_num = (defaultSlot <= numChannels ? defaultSlot : 1); + THIS->controller->sendConfig(meshtastic_Config_LoRaConfig{lora}, THIS->ownNode); + } + + char buf[30]; + const char *userShort = lv_textarea_get_text(objects.setup_user_short_textarea); + const char *userLong = lv_textarea_get_text(objects.setup_user_long_textarea); + if (strcmp(userShort, THIS->db.short_name) || strcmp(userLong, THIS->db.long_name)) { + lv_snprintf(buf, sizeof(buf), _("User name: %s"), userShort); + lv_label_set_text(objects.basic_settings_user_label, buf); + lv_label_set_text(objects.user_name_short_label, userShort); + lv_label_set_text(objects.user_name_label, userLong); + strcpy(THIS->db.short_name, userShort); + strcpy(THIS->db.long_name, userLong); + meshtastic_User user{}; // TODO: don't overwrite is_licensed + strcpy(user.short_name, userShort); + strcpy(user.long_name, userLong); + THIS->controller->sendConfig(user, THIS->ownNode); + } + THIS->notifyReboot(true); + + lv_obj_add_flag(objects.initial_setup_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.home_button); + break; + } + case eUsername: { + char buf[30]; + const char *userShort = lv_textarea_get_text(objects.settings_user_short_textarea); + const char *userLong = lv_textarea_get_text(objects.settings_user_long_textarea); + if (strcmp(userShort, THIS->db.short_name) || strcmp(userLong, THIS->db.long_name)) { + lv_snprintf(buf, sizeof(buf), _("User name: %s"), userShort); + lv_label_set_text(objects.basic_settings_user_label, buf); + lv_label_set_text(objects.user_name_short_label, userShort); + lv_label_set_text(objects.user_name_label, userLong); + strcpy(THIS->db.short_name, userShort); + strcpy(THIS->db.long_name, userLong); + meshtastic_User user{}; // TODO: don't overwrite is_licensed + strcpy(user.short_name, userShort); + strcpy(user.long_name, userLong); + THIS->controller->sendConfig(user, THIS->ownNode); + THIS->notifyReboot(true); + } + lv_obj_add_flag(objects.settings_username_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_user_button); + break; + } + case eDeviceRole: { + meshtastic_Config_DeviceConfig &device = THIS->db.config.device; + meshtastic_Config_DeviceConfig_Role role = + THIS->val2role(lv_dropdown_get_selected(objects.settings_device_role_dropdown)); + + if (role != device.role) { + char buf1[30], buf2[40]; + lv_dropdown_get_selected_str(objects.settings_device_role_dropdown, buf1, sizeof(buf1)); + lv_snprintf(buf2, sizeof(buf2), _("Device Role: %s"), buf1); + lv_label_set_text(objects.basic_settings_role_label, buf2); + + device.role = role; + THIS->controller->sendConfig(meshtastic_Config_DeviceConfig{device}, THIS->ownNode); + THIS->notifyReboot(true); + } + lv_obj_add_flag(objects.settings_device_role_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_role_button); + break; + } + case eRegion: { + meshtastic_Config_LoRaConfig_RegionCode region = + (meshtastic_Config_LoRaConfig_RegionCode)(lv_dropdown_get_selected(objects.settings_region_dropdown) + 1); + + uint32_t numChannels = LoRaPresets::getNumChannels(region, THIS->db.config.lora.modem_preset); + if (numChannels == 0) { + // region not possible for selected preset, revert + lv_dropdown_set_selected(objects.settings_region_dropdown, THIS->db.config.lora.region - 1); + return; + } + + if (region != THIS->db.config.lora.region) { + char buf1[10], buf2[30]; + lv_dropdown_get_selected_str(objects.settings_region_dropdown, buf1, sizeof(buf1)); + lv_snprintf(buf2, sizeof(buf2), _("Region: %s"), buf1); + lv_label_set_text(objects.basic_settings_region_label, buf2); + + meshtastic_Config_LoRaConfig &lora = THIS->db.config.lora; + uint32_t defaultSlot = lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET ? lora.channel_num : 0; + if (defaultSlot == 0) { + defaultSlot = + LoRaPresets::getDefaultSlot(region, THIS->db.config.lora.modem_preset, THIS->db.channel[0].settings.name); + } + lora.region = region; + lora.channel_num = (defaultSlot <= numChannels ? defaultSlot : 1); + THIS->controller->sendConfig(meshtastic_Config_LoRaConfig{lora}, THIS->ownNode); + THIS->notifyReboot(true); + } + lv_obj_add_flag(objects.settings_region_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_region_button); + break; + } + case eModemPreset: { + meshtastic_Config_LoRaConfig &lora = THIS->db.config.lora; + meshtastic_Config_LoRaConfig_ModemPreset preset = + THIS->val2preset(lv_dropdown_get_selected(objects.settings_modem_preset_dropdown)); + uint16_t channelNum = lv_slider_get_value(objects.frequency_slot_slider); + if (preset != lora.modem_preset || lora.channel_num != channelNum) { + char buf1[16], buf2[32]; + lv_dropdown_get_selected_str(objects.settings_modem_preset_dropdown, buf1, sizeof(buf1)); + lv_snprintf(buf2, sizeof(buf2), _("Modem Preset: %s"), buf1); + lv_label_set_text(objects.basic_settings_modem_preset_label, buf2); + + lora.use_preset = true; + lora.modem_preset = preset; + lora.channel_num = channelNum; + THIS->controller->sendConfig(meshtastic_Config_LoRaConfig{lora}, THIS->ownNode); + THIS->notifyReboot(true); + } + lv_obj_add_flag(objects.settings_modem_preset_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_modem_preset_button); + break; + } + case eChannel: { + for (int i = 0; i < c_max_channels; i++) { + // check if channel changed, then update and send to radio + if (memcmp(&THIS->db.channel[i], &THIS->channel_scratch[i], sizeof(THIS->channel_scratch[i])) != 0) { + THIS->channel_scratch[i].has_settings = true; + THIS->updateChannelConfig(THIS->channel_scratch[i]); + THIS->controller->sendConfig(THIS->channel_scratch[i], THIS->ownNode); + } + } + + int8_t ch = (signed long)THIS->ch_label[0]->user_data; + THIS->setChannelName(THIS->db.channel[ch]); + lv_obj_clear_state(objects.settings_channel_panel, LV_STATE_DISABLED); + lv_obj_add_flag(objects.settings_channel_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_channel_button); + delete[] THIS->channel_scratch; + break; + } + case eWifi: { + char buf[30]; + const char *ssid = lv_textarea_get_text(objects.settings_wifi_ssid_textarea); + const char *psk = lv_textarea_get_text(objects.settings_wifi_password_textarea); + if (strlen(ssid) == 0 || strlen(psk) == 0) + return; + lv_snprintf(buf, sizeof(buf), _("WiFi: %s"), ssid[0] ? ssid : _("")); + lv_label_set_text(objects.basic_settings_wifi_label, buf); + if (strcmp(THIS->db.config.network.wifi_ssid, ssid) != 0 || strcmp(THIS->db.config.network.wifi_psk, psk) != 0) { + strcpy(THIS->db.config.network.wifi_ssid, ssid); + strcpy(THIS->db.config.network.wifi_psk, psk); + THIS->db.config.network.wifi_enabled = true; + THIS->controller->sendConfig(meshtastic_Config_NetworkConfig{THIS->db.config.network}, THIS->ownNode); + THIS->notifyReboot(true); + } + THIS->enablePanel(objects.home_panel); + lv_obj_add_flag(objects.settings_wifi_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_wifi_button); + break; + } + case eLanguage: { + uint32_t value = lv_dropdown_get_selected(objects.settings_language_dropdown); + meshtastic_Language lang = THIS->val2language(value); + if (lang != THIS->db.uiConfig.language) { + THIS->db.uiConfig.language = lang; + THIS->controller->storeUIConfig(THIS->db.uiConfig); + THIS->controller->requestReboot(3, THIS->ownNode); + THIS->notifyReboot(true); + } + + lv_obj_add_flag(objects.settings_language_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_language_button); + break; + } + case eScreenTimeout: { + uint32_t value = lv_slider_get_value(objects.screen_timeout_slider); + if (value > 5) + value -= value % 5; + if (value != THIS->db.uiConfig.screen_timeout) { + THIS->setTimeout(value); + THIS->db.uiConfig.screen_timeout = value; + THIS->controller->storeUIConfig(THIS->db.uiConfig); + } + lv_obj_add_flag(objects.settings_screen_timeout_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_timeout_button); + break; + } + case eScreenLock: { + const char *pin = lv_textarea_get_text(objects.settings_screen_lock_password_textarea); + bool screenLock = lv_obj_has_state(objects.settings_screen_lock_switch, LV_STATE_CHECKED); + bool settingsLock = lv_obj_has_state(objects.settings_settings_lock_switch, LV_STATE_CHECKED); + if ((screenLock || settingsLock) && (atol(pin) == 0 || strlen(pin) != 6)) + return; // require pin != "000000" + if ((screenLock != THIS->db.uiConfig.screen_lock) || settingsLock != THIS->db.uiConfig.settings_lock || + atol(pin) != THIS->db.uiConfig.pin_code) { + THIS->db.uiConfig.screen_lock = screenLock; + THIS->db.uiConfig.settings_lock = settingsLock; + THIS->db.uiConfig.pin_code = atol(pin); + THIS->controller->storeUIConfig(THIS->db.uiConfig); + } + + char buf[40]; + lv_snprintf(buf, 40, _("Lock: %s/%s"), screenLock ? _("on") : _("off"), settingsLock ? _("on") : _("off")); + lv_label_set_text(objects.basic_settings_screen_lock_label, buf); + lv_obj_add_flag(objects.settings_screen_lock_panel, LV_OBJ_FLAG_HIDDEN); + + break; + } + case eScreenBrightness: { + int32_t value = lv_slider_get_value(objects.brightness_slider) * 255 / 100; + if (value != THIS->db.uiConfig.screen_brightness) { + THIS->setBrightness(value); + THIS->db.uiConfig.screen_brightness = value; + THIS->controller->storeUIConfig(THIS->db.uiConfig); + } + lv_obj_add_flag(objects.settings_brightness_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_brightness_button); + break; + } + case eTheme: { + uint32_t value = lv_dropdown_get_selected(objects.settings_theme_dropdown); + if (value != THIS->db.uiConfig.theme) { + THIS->setTheme(value); + THIS->db.uiConfig.theme = meshtastic_Theme(value); + THIS->controller->storeUIConfig(THIS->db.uiConfig); + lv_obj_set_style_bg_img_recolor(objects.settings_button, colorMesh, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + lv_obj_add_flag(objects.settings_theme_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_theme_button); + lv_obj_invalidate(objects.main_screen); + break; + } + case eInputControl: { + char new_val_kbd[10], new_val_ptr[10]; + lv_dropdown_get_selected_str(objects.settings_keyboard_input_dropdown, new_val_kbd, sizeof(new_val_kbd)); + lv_dropdown_get_selected_str(objects.settings_mouse_input_dropdown, new_val_ptr, sizeof(new_val_ptr)); + + bool error = false; + if (strcmp(THIS->old_val1_scratch, new_val_kbd) != 0) { + if (strcmp(THIS->old_val1_scratch, _("none")) != 0) { + THIS->inputdriver->releaseKeyboardDevice(); + } + if (strcmp(new_val_kbd, _("none")) != 0) { + error &= THIS->inputdriver->useKeyboardDevice(new_val_kbd); + } + } + if (strcmp(THIS->old_val2_scratch, new_val_ptr) != 0) { + if (strcmp(THIS->old_val2_scratch, _("none")) != 0) { + THIS->inputdriver->releasePointerDevice(); + } + if (strcmp(new_val_ptr, _("none")) != 0) { + error &= THIS->inputdriver->usePointerDevice(new_val_ptr); + } + } + + THIS->setInputButtonLabel(); + + if (error) { + ILOG_WARN("failed to use %s/%s", new_val_kbd, new_val_ptr); + return; + } + + std::string current_kbd = THIS->inputdriver->getCurrentKeyboardDevice(); + std::string current_ptr = THIS->inputdriver->getCurrentPointerDevice(); + if (strcmp(current_kbd.c_str(), _("none")) == 0 && strcmp(current_ptr.c_str(), _("none")) == 0 && THIS->input_group) { + lv_group_delete(THIS->input_group); + THIS->input_group = nullptr; + } else if (strcmp(THIS->old_val1_scratch, current_kbd.c_str()) != 0 || + strcmp(THIS->old_val2_scratch, current_ptr.c_str()) != 0) { + THIS->setInputGroup(); + } + + lv_obj_add_flag(objects.settings_input_control_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_input_button); + break; + } + case eAlertBuzzer: { + meshtastic_ModuleConfig_ExternalNotificationConfig &config = THIS->db.module_config.external_notification; + int tone = lv_dropdown_get_selected(objects.settings_ringtone_dropdown) + 1; + + bool silent = false; + bool alert_message = lv_obj_has_state(objects.settings_alert_buzzer_switch, LV_STATE_CHECKED); + if ((!config.enabled || !config.alert_message_buzzer) && alert_message) { + if (!config.enabled || !config.alert_message_buzzer || !config.use_pwm || !config.use_i2s_as_buzzer) { + config.enabled = true; + config.alert_message_buzzer = true; + config.use_pwm = true; + config.nag_timeout = 0; +#ifdef USE_I2S_BUZZER + config.use_i2s_as_buzzer = true; + config.use_pwm = false; +#endif + } + THIS->notifyReboot(true); + THIS->controller->sendConfig(meshtastic_ModuleConfig_ExternalNotificationConfig{config}, THIS->ownNode); + } else if (config.alert_message_buzzer && !alert_message) { + silent = true; + } + + THIS->controller->sendConfig(ringtone[silent ? 0 : tone].rtttl, THIS->ownNode); + THIS->db.uiConfig.ring_tone_id = tone; + THIS->db.silent = silent; + THIS->db.uiConfig.alert_enabled = !silent; + THIS->setBellText(THIS->db.uiConfig.alert_enabled, !silent); + THIS->controller->storeUIConfig(THIS->db.uiConfig); + + lv_obj_add_flag(objects.settings_alert_buzzer_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_alert_button); + break; + } + case eBackupRestore: { + uint32_t option = lv_dropdown_get_selected(objects.settings_backup_restore_dropdown); + if (lv_obj_has_state(objects.settings_backup_checkbox, LV_STATE_CHECKED)) { + THIS->backup(option); + } else if (lv_obj_has_state(objects.settings_restore_checkbox, LV_STATE_CHECKED)) { + THIS->restore(option); + } + lv_obj_add_flag(objects.settings_backup_restore_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_backup_restore_button); + break; + } + case eReset: { + uint32_t option = lv_dropdown_get_selected(objects.settings_reset_dropdown); + if (option == 2) { + THIS->clearChatHistory(); + } else { + THIS->notifyReboot(true); + THIS->controller->requestReset(option, THIS->ownNode); + } + lv_obj_add_flag(objects.settings_reset_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_reset_button); + break; + } + case eDisplayMode: { + meshtastic_Config_DisplayConfig &display = THIS->db.config.display; + meshtastic_Config_BluetoothConfig &bluetooth = THIS->db.config.bluetooth; + display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT; + bluetooth.enabled = true; + THIS->controller->sendConfig(meshtastic_Config_DisplayConfig{display}, THIS->ownNode); + THIS->controller->sendConfig(meshtastic_Config_BluetoothConfig{bluetooth}, THIS->ownNode); + THIS->controller->requestReboot(5, THIS->ownNode); + lv_screen_load_anim(objects.blank_screen, LV_SCR_LOAD_ANIM_FADE_OUT, 4000, 1000, false); + lv_obj_add_flag(objects.reboot_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.settings_reboot_panel, LV_OBJ_FLAG_HIDDEN); + break; + } + case eModifyChannel: { + meshtastic_ChannelSettings_psk_t psk = {}; + const char *name = lv_textarea_get_text(objects.settings_modify_channel_name_textarea); + const char *base64 = lv_textarea_get_text(objects.settings_modify_channel_psk_textarea); + uint8_t btn_id = (unsigned long)objects.settings_modify_channel_name_textarea->user_data; + int8_t ch = (signed long)THIS->ch_label[btn_id]->user_data; + + if (strlen(base64) == 0 && strlen(name) == 0) { + // delete channel + THIS->channel_scratch[ch].role = meshtastic_Channel_Role_DISABLED; + THIS->channel_scratch[ch].settings.psk.size = 0; + memset(THIS->channel_scratch[ch].settings.name, 0, sizeof(THIS->channel_scratch[ch].settings.name)); + memset(THIS->channel_scratch[ch].settings.psk.bytes, 0, sizeof(THIS->channel_scratch[ch].settings.psk.bytes)); + THIS->channel_scratch[ch].has_settings = false; + lv_label_set_text(THIS->ch_label[btn_id], _("")); + THIS->activeSettings = eChannel; + } else { + int paddings = (4 - strlen(base64) % 4) % 4; + while (paddings-- > 0) { + lv_textarea_add_text(objects.settings_modify_channel_psk_textarea, "="); + } + + if (THIS->base64ToPsk(lv_textarea_get_text(objects.settings_modify_channel_psk_textarea), psk.bytes, psk.size)) { + if (strlen(name) || psk.size) { + // TODO: fill temp storage -> user data + lv_label_set_text(THIS->ch_label[btn_id], name); + strcpy(THIS->channel_scratch[ch].settings.name, name); + memcpy(THIS->channel_scratch[ch].settings.psk.bytes, psk.bytes, 32); + THIS->channel_scratch[ch].settings.psk.size = psk.size; + THIS->activeSettings = eChannel; + } + } + THIS->channel_scratch[ch].role = (ch == 0) ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY; + } + if (THIS->activeSettings == eChannel) { + lv_obj_add_flag(objects.settings_modify_channel_panel, LV_OBJ_FLAG_HIDDEN); + THIS->enablePanel(objects.settings_channel_panel); + lv_group_focus_obj(objects.settings_channel0_button); + } + return; + } + default: + ILOG_ERROR("Unhandled ok event"); + break; + } + THIS->enablePanel(objects.controller_panel); + THIS->enablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eNone; + } +} + +/** + * @brief Cancel button user widget handling + * + * @param e + */ +void TFTView_480x222::ui_event_cancel(lv_event_t *e) +{ + lv_event_code_t event_code = lv_event_get_code(e); + if (event_code == LV_EVENT_CLICKED) { + switch (THIS->activeSettings) { + case TFTView_480x222::eSetup: { + THIS->ui_set_active(objects.home_button, objects.home_panel, objects.top_panel); + // lv_obj_add_flag(objects.initial_setup_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.home_button); + break; + } + case TFTView_480x222::eUsername: { + lv_obj_add_flag(objects.settings_username_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_user_button); + break; + } + case TFTView_480x222::eDeviceRole: { + lv_obj_add_flag(objects.settings_device_role_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_role_button); + break; + } + case TFTView_480x222::eRegion: { + lv_obj_add_flag(objects.settings_region_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_region_button); + break; + } + case TFTView_480x222::eModemPreset: { + lv_obj_add_flag(objects.settings_modem_preset_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_modem_preset_button); + break; + } + case TFTView_480x222::eChannel: { + delete[] THIS->channel_scratch; + lv_obj_add_flag(objects.settings_channel_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_channel_button); + break; + } + case TFTView_480x222::eWifi: { + lv_obj_add_flag(objects.settings_wifi_panel, LV_OBJ_FLAG_HIDDEN); + THIS->enablePanel(objects.home_panel); + lv_group_focus_obj(objects.home_wlan_button); + break; + } + case TFTView_480x222::eLanguage: { + lv_obj_add_flag(objects.settings_language_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_language_button); + break; + } + case TFTView_480x222::eScreenTimeout: { + lv_obj_add_flag(objects.settings_screen_timeout_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_timeout_button); + break; + } + case eScreenLock: { + lv_obj_add_flag(objects.settings_screen_lock_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_screen_lock_button); + break; + } + case TFTView_480x222::eScreenBrightness: { + lv_obj_add_flag(objects.settings_brightness_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_brightness_button); + // revert to old brightness value + uint32_t old_brightness = THIS->db.uiConfig.screen_brightness; + THIS->displaydriver->setBrightness((uint8_t)old_brightness); + break; + } + case TFTView_480x222::eTheme: { + lv_obj_add_flag(objects.settings_theme_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_theme_button); + break; + } + case TFTView_480x222::eInputControl: { + lv_obj_add_flag(objects.settings_input_control_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_input_button); + break; + } + case TFTView_480x222::eAlertBuzzer: { + lv_obj_add_flag(objects.settings_alert_buzzer_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_alert_button); + break; + } + case TFTView_480x222::eBackupRestore: { + lv_obj_add_flag(objects.settings_backup_restore_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_backup_restore_button); + break; + } + case TFTView_480x222::eReset: { + lv_obj_add_flag(objects.settings_reset_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_reset_button); + break; + } + case TFTView_480x222::eDisplayMode: { + lv_obj_add_flag(objects.settings_reboot_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.basic_settings_reset_button); + break; + } + case TFTView_480x222::eModifyChannel: { + lv_obj_add_flag(objects.settings_modify_channel_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.settings_channel0_button); + THIS->enablePanel(objects.settings_channel_panel); + THIS->activeSettings = eChannel; + return; + } + default: + ILOG_ERROR("Unhandled cancel event"); + break; + } + + THIS->enablePanel(objects.controller_panel); + THIS->enablePanel(objects.tab_page_basic_settings); + THIS->activeSettings = eNone; + } +} + +// end button event handlers + +void TFTView_480x222::ui_event_screen_timeout_slider(lv_event_t *e) +{ + lv_obj_t *slider = lv_event_get_target_obj(e); + char buf[20]; + uint32_t value = lv_slider_get_value(slider); + if (value > 5) + value -= value % 5; + if (value == 0) + lv_snprintf(buf, sizeof(buf), _("Timeout: off")); + else + lv_snprintf(buf, sizeof(buf), _("Timeout: %ds"), value); + lv_label_set_text(objects.settings_screen_timeout_label, buf); +} + +void TFTView_480x222::ui_event_brightness_slider(lv_event_t *e) +{ + lv_obj_t *slider = lv_event_get_target_obj(e); + char buf[20]; + lv_snprintf(buf, sizeof(buf), _("Brightness: %d%%"), (int)lv_slider_get_value(slider)); + lv_label_set_text(objects.settings_brightness_label, buf); + THIS->displaydriver->setBrightness((uint8_t)(lv_slider_get_value(slider) * 255 / 100)); +} + +void TFTView_480x222::ui_event_frequency_slot_slider(lv_event_t *e) +{ + lv_obj_t *slider = lv_event_get_target_obj(e); + char buf[40]; + uint32_t channel = (uint32_t)lv_slider_get_value(slider); + sprintf(buf, _("FrequencySlot: %d (%g MHz)"), channel, + LoRaPresets::getRadioFreq(THIS->db.config.lora.region, + THIS->val2preset(lv_dropdown_get_selected(objects.settings_modem_preset_dropdown)), + channel)); + lv_label_set_text(objects.frequency_slot_label, buf); +} + +void TFTView_480x222::ui_event_modem_preset_dropdown(lv_event_t *e) +{ + lv_obj_t *dropdown = lv_event_get_target_obj(e); + meshtastic_Config_LoRaConfig_ModemPreset preset = + (meshtastic_Config_LoRaConfig_ModemPreset)lv_dropdown_get_selected(dropdown); + uint32_t numChannels = LoRaPresets::getNumChannels(THIS->db.config.lora.region, preset); + if (numChannels == 0) { + // preset not possible for this region, revert + lv_dropdown_set_selected(dropdown, THIS->db.config.lora.modem_preset); + numChannels = LoRaPresets::getNumChannels(THIS->db.config.lora.region, THIS->db.config.lora.modem_preset); + return; + } + + uint32_t channel = LoRaPresets::getDefaultSlot(THIS->db.config.lora.region, preset, THIS->db.channel[0].settings.name); + if (channel > numChannels) + channel = 1; + lv_slider_set_range(objects.frequency_slot_slider, 1, numChannels); + lv_slider_set_value(objects.frequency_slot_slider, channel, LV_ANIM_ON); + + char buf[40]; + sprintf(buf, _("FrequencySlot: %d (%g MHz)"), channel, + LoRaPresets::getRadioFreq(THIS->db.config.lora.region, preset, channel)); + lv_label_set_text(objects.frequency_slot_label, buf); +} + +void TFTView_480x222::ui_event_setup_region_dropdown(lv_event_t *e) {} + +// animations +void TFTView_480x222::ui_anim_node_panel_cb(void *var, int32_t v) +{ + lv_obj_set_height((lv_obj_t *)var, v); +} + +void TFTView_480x222::ui_anim_radar_cb(void *var, int32_t r) +{ + lv_img_set_angle(objects.radar_beam, r); +} + +/** + * @brief Dynamically show user widget + * First a panel is created where the widget is located in, then the widget is drawn. + * "active_widget" contains the surrounding panel which must be destroyed + * to remove the widget from the screen (e.g. by pressing OK/Cancel). + * + * @param func + */ +void TFTView_480x222::showUserWidget(UserWidgetFunc createWidget) +{ + lv_obj_t *obj = lv_obj_create(objects.main_screen); + lv_obj_set_pos(obj, 39, 25); + lv_obj_set_size(obj, LV_PCT(88), LV_PCT(90)); + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(obj, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + activeWidget = obj; + + createWidget(activeWidget, NULL, 0); +} + +void TFTView_480x222::handleAddMessage(char *msg) +{ + // retrieve nodeNum + channel from activeMsgContainer + uint32_t to = UINT32_MAX; + uint8_t ch = 0; + uint8_t hopLimit = db.config.lora.hop_limit; + uint32_t requestId; + uint32_t channelOrNode = (unsigned long)activeMsgContainer->user_data; + bool usePkc = false; + + auto callback = [this](const ResponseHandler::Request &req, ResponseHandler::EventType evt, int32_t pass) { + this->onTextMessageCallback(req, evt, pass); + }; + + if (channelOrNode < c_max_channels) { + ch = (uint8_t)channelOrNode; + requestId = requests.addRequest(ch, ResponseHandler::TextMessageRequest, (void *)(long)ch, callback); + } else { + ch = (uint8_t)(unsigned long)nodes[channelOrNode]->user_data; + to = channelOrNode; + usePkc = (unsigned long)nodes[to]->LV_OBJ_IDX(node_bat_idx)->user_data; // hasKey + requestId = requests.addRequest(to, ResponseHandler::TextMessageRequest, (void *)to, callback); + // trial: hoplimit optimization for direct text messages + int8_t hopsAway = (signed long)nodes[to]->LV_OBJ_IDX(node_sig_idx)->user_data; + if (hopsAway < 0) + hopsAway = db.config.lora.hop_limit; + hopLimit = (hopsAway < db.config.lora.hop_limit ? hopsAway + 1 : hopsAway); + } + + // tweak to allow multiple lines in single line text area + for (int i = 0; i < strlen(msg); i++) + if (msg[i] == CR_REPLACEMENT) + msg[i] = '\n'; + + controller->sendTextMessage(to, ch, hopLimit, actTime, requestId, usePkc, msg); + addMessage(activeMsgContainer, actTime, requestId, msg, LogMessage::eNone); +} + +/** + * display message that has just been written and sent out + */ +void TFTView_480x222::addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t requestId, char *msg, + LogMessage::MsgStatus status) +{ + lv_obj_t *hiddenPanel = lv_obj_create(container); + lv_obj_set_width(hiddenPanel, lv_pct(100)); + lv_obj_set_height(hiddenPanel, LV_SIZE_CONTENT); + lv_obj_set_align(hiddenPanel, LV_ALIGN_CENTER); + lv_obj_clear_flag(hiddenPanel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + add_style_panel_style(hiddenPanel); + + lv_obj_set_style_border_width(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + hiddenPanel->user_data = (void *)requestId; + + // add timestamp + char buf[284]; // 237 + 4 + 40 + 2 + 1 + buf[0] = '\0'; + uint32_t len = timestamp(buf, msgTime, status == LogMessage::eNone); + strcat(&buf[len], msg); + + lv_obj_t *textLabel = lv_label_create(hiddenPanel); + // calculate expected size of text bubble, to make it look nicer + lv_coord_t width = lv_txt_get_width(buf, strlen(buf), &ui_font_montserrat_12, 0); + lv_obj_set_width(textLabel, std::max(std::min(width, 200) + 10, 40)); + lv_obj_set_height(textLabel, LV_SIZE_CONTENT); + lv_obj_set_y(textLabel, 0); + lv_obj_set_align(textLabel, LV_ALIGN_RIGHT_MID); + lv_label_set_text(textLabel, buf); + + add_style_chat_message_style(textLabel); + + lv_obj_scroll_to_view(hiddenPanel, LV_ANIM_ON); + lv_obj_move_foreground(objects.message_input_area); + + switch (status) { + case LogMessage::eHeard: + lv_obj_set_style_border_color(textLabel, colorYellow, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case LogMessage::eAcked: + lv_obj_set_style_border_color(textLabel, colorBlueGreen, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case LogMessage::eFailed: + lv_obj_set_style_border_color(textLabel, colorRed, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + default: + break; + } +} + +void TFTView_480x222::addNode(uint32_t nodeNum, uint8_t ch, const char *userShort, const char *userLong, uint32_t lastHeard, + eRole role, bool hasKey, bool unmessagable) +{ + // lv_obj nodesPanel children | user data (4 bytes) + // ================================================== + // [0]: img | role + // [1]: btn | ll group + // [2]: lbl user long | nodeNum + // [3]: lbl user short | userShort (4 chars) + // [4]: lbl battery | hasKey + // [5]: lbl lastHeard | lastHeard / curtime + // [6]: lbl signal (or hops) | hops away + // [7]: lbl position 1 | lat + // [8]: lbl position 2 | lon + // [9]: lbl telemetry 1 | + // [10]: lbl telemetry 2 | iaq + // panel user_data: ch + + ILOG_DEBUG("addNode(%d): num=0x%08x, lastseen=%d, name=%s(%s), role=%d", nodeCount, nodeNum, lastHeard, userLong, userShort, + role); + while (nodeCount >= MAX_NUM_NODES_VIEW) { + purgeNode(nodeNum); + } + + lv_obj_t *p = lv_obj_create(objects.nodes_panel); + lv_ll_t *lv_group_ll = &lv_group_get_default()->obj_ll; + + p->user_data = (void *)(uint32_t)ch; + nodes[nodeNum] = p; + nodeCount++; + + // NodePanel + lv_obj_set_pos(p, LV_PCT(0), 0); + lv_obj_set_size(p, LV_PCT(100), 53); + lv_obj_set_align(p, LV_ALIGN_CENTER); + lv_obj_set_style_pad_top(p, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(p, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_remove_flag(p, lv_obj_flag_t(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_PRESS_LOCK | LV_OBJ_FLAG_CLICK_FOCUSABLE | + LV_OBJ_FLAG_GESTURE_BUBBLE | LV_OBJ_FLAG_SNAPPABLE | LV_OBJ_FLAG_SCROLLABLE)); + add_style_node_panel_style(p); + + // NodeImage + lv_obj_t *img = lv_img_create(p); + setNodeImage(nodeNum, role, unmessagable, img); + lv_obj_set_pos(img, -5, 3); + lv_obj_set_size(img, 32, 32); + lv_obj_clear_flag(img, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(img, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(img, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + if (!hasKey) { + lv_obj_set_style_border_color(img, colorRed, LV_PART_MAIN | LV_STATE_DEFAULT); + } + if (unmessagable) { + // node role icon is not clickable and replaced with a cancelled icon + img->user_data = (void *)eRole::unmessagable; + } else { + img->user_data = (void *)role; + } + + // NodeButton + lv_obj_t *nodeButton = lv_btn_create(p); + lv_obj_set_pos(nodeButton, 0, 0); + lv_obj_set_size(nodeButton, LV_PCT(106), LV_PCT(100)); + add_style_node_button_style(nodeButton); + lv_obj_set_align(nodeButton, LV_ALIGN_CENTER); + lv_obj_add_flag(nodeButton, LV_OBJ_FLAG_SCROLL_ON_FOCUS); + lv_obj_set_style_shadow_width(nodeButton, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_max_height(nodeButton, 132, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_min_height(nodeButton, 50, LV_PART_MAIN | LV_STATE_DEFAULT); + nodeButton->user_data = _lv_ll_get_tail(lv_group_ll); + + // UserNameLabel + lv_obj_t *ln_lbl = lv_label_create(p); + lv_obj_set_pos(ln_lbl, -5, 35); + lv_obj_set_size(ln_lbl, LV_PCT(80), LV_SIZE_CONTENT); + lv_label_set_long_mode(ln_lbl, LV_LABEL_LONG_SCROLL); + lv_label_set_text(ln_lbl, userLong); + ln_lbl->user_data = (void *)nodeNum; + lv_obj_set_style_align(ln_lbl, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + + // UserNameShortLabel + lv_obj_t *sn_lbl = lv_label_create(p); + lv_obj_set_pos(sn_lbl, 30, 10); + lv_obj_set_size(sn_lbl, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_label_set_long_mode(sn_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_style_align(sn_lbl, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(sn_lbl, &ui_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + // if short name contains only non-printable glyphs replace with short id + if (lv_txt_get_width(userShort, strlen(userShort), &ui_font_montserrat_14, 0) <= 4) { + lv_label_set_text_fmt(sn_lbl, "%04x", nodeNum & 0xffff); + } else { + lv_label_set_text(sn_lbl, userShort); + } + char *modUserShort = lv_label_get_text(sn_lbl); + + // keep a copy of the (4-byte) short name for use in many other widgets + char *userData = (char *)&(sn_lbl->user_data); + userData[0] = modUserShort[0]; + if (userData[0] == 0x00) + userData[0] = ' '; + userData[1] = modUserShort[1]; + if (userData[1] == 0x00) + userData[1] = ' '; + userData[2] = modUserShort[2]; + if (userData[2] == 0x00) + userData[2] = ' '; + userData[3] = modUserShort[3]; + if (userData[3] == 0x00) + userData[3] = ' '; + + // BatteryLabel + lv_obj_t *ui_BatteryLabel = lv_label_create(p); + lv_obj_set_pos(ui_BatteryLabel, 8, 17); + lv_obj_set_size(ui_BatteryLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_align(ui_BatteryLabel, LV_ALIGN_TOP_RIGHT); + lv_label_set_text(ui_BatteryLabel, ""); + lv_obj_set_style_text_align(ui_BatteryLabel, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + ui_BatteryLabel->user_data = (void *)hasKey; + // LastHeardLabel + lv_obj_t *ui_lastHeardLabel = lv_label_create(p); + lv_obj_set_pos(ui_lastHeardLabel, 8, 33); + lv_obj_set_size(ui_lastHeardLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_align(ui_lastHeardLabel, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_long_mode(ui_lastHeardLabel, LV_LABEL_LONG_CLIP); + + // TODO: devices without actual time will report all nodes as lastseen = now + if (lastHeard) { + lastHeard = std::min(curtime, (time_t)lastHeard); // adapt values too large + + char buf[20]; + bool isOnline = lastHeardToString(lastHeard, buf); + lv_label_set_text(ui_lastHeardLabel, buf); + if (isOnline) { + nodesOnline++; + } + } else { + lv_label_set_text(ui_lastHeardLabel, ""); + } + + lv_obj_set_style_text_align(ui_lastHeardLabel, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + ui_lastHeardLabel->user_data = (void *)lastHeard; + // SignalLabel / hopsAway + lv_obj_t *ui_SignalLabel = lv_label_create(p); + lv_obj_set_width(ui_SignalLabel, LV_SIZE_CONTENT); + lv_obj_set_height(ui_SignalLabel, LV_SIZE_CONTENT); + lv_obj_set_pos(ui_SignalLabel, 8, 1); + lv_obj_set_align(ui_SignalLabel, LV_ALIGN_TOP_RIGHT); + lv_label_set_text(ui_SignalLabel, ""); + ui_SignalLabel->user_data = (void *)-1; // TODO viaMqtt; // used for filtering (applyNodesFilter) + // PositionLabel + lv_obj_t *ui_PositionLabel = lv_label_create(p); + lv_obj_set_pos(ui_PositionLabel, -5, 49); + lv_obj_set_size(ui_PositionLabel, 120, LV_SIZE_CONTENT); + lv_label_set_long_mode(ui_PositionLabel, LV_LABEL_LONG_CLIP); + lv_label_set_text(ui_PositionLabel, ""); + lv_obj_set_style_align(ui_PositionLabel, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(ui_PositionLabel, colorBlueGreen, LV_PART_MAIN | LV_STATE_DEFAULT); + ui_PositionLabel->user_data = 0; // store latitude + // Position2Label + lv_obj_t *ui_Position2Label = lv_label_create(p); + lv_obj_set_pos(ui_Position2Label, -5, 63); + lv_obj_set_size(ui_Position2Label, 108, LV_SIZE_CONTENT); + lv_label_set_long_mode(ui_Position2Label, LV_LABEL_LONG_SCROLL); + lv_label_set_text(ui_Position2Label, ""); + lv_obj_set_style_align(ui_Position2Label, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + ui_Position2Label->user_data = 0; // store longitude + // Telemetry1Label + lv_obj_t *ui_Telemetry1Label = lv_label_create(p); + lv_obj_set_pos(ui_Telemetry1Label, 8, 49); + lv_obj_set_size(ui_Telemetry1Label, 130, LV_SIZE_CONTENT); + lv_label_set_long_mode(ui_Telemetry1Label, LV_LABEL_LONG_CLIP); + lv_label_set_text(ui_Telemetry1Label, ""); + lv_obj_set_style_align(ui_Telemetry1Label, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(ui_Telemetry1Label, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + // Telemetry2Label + lv_obj_t *ui_Telemetry2Label = lv_label_create(p); + lv_obj_set_pos(ui_Telemetry2Label, 8, 63); + lv_obj_set_size(ui_Telemetry2Label, 130, LV_SIZE_CONTENT); + lv_label_set_long_mode(ui_Telemetry2Label, LV_LABEL_LONG_CLIP); + lv_label_set_text(ui_Telemetry2Label, ""); + lv_obj_set_style_align(ui_Telemetry2Label, LV_ALIGN_TOP_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(ui_Telemetry2Label, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_add_event_cb(nodeButton, ui_event_NodeButton, LV_EVENT_ALL, (void *)nodeNum); + + // move node into new position within nodePanel + if (lastHeard) { + lv_obj_t **children = objects.nodes_panel->spec_attr->children; + int i = objects.nodes_panel->spec_attr->child_cnt - 1; + while (i > 1) { + if (lastHeard <= (time_t)(children[i - 1]->LV_OBJ_IDX(node_lh_idx)->user_data)) + break; + i--; + } + if (i >= 1 && i < objects.nodes_panel->spec_attr->child_cnt - 1) { + lv_obj_move_to_index(p, i); + // re-arrange the group linked list by moving the new button (now at the tail) into the right position + void *after = children[i + 1]->LV_OBJ_IDX(node_btn_idx)->user_data; + _lv_ll_move_before(lv_group_ll, nodeButton->user_data, after); + } + } + + if (!nodesChanged) { + applyNodesFilter(nodeNum); + updateNodesStatus(); + } +} + +void TFTView_480x222::setMyInfo(uint32_t nodeNum) +{ + ownNode = nodeNum; +} + +void TFTView_480x222::setDeviceMetaData(int hw_model, const char *version, bool has_bluetooth, bool has_wifi, bool has_eth, + bool can_shutdown) +{ +} + +void TFTView_480x222::addOrUpdateNode(uint32_t nodeNum, uint8_t channel, uint32_t lastHeard, const meshtastic_User &cfg) +{ + if (nodes.find(nodeNum) == nodes.end()) { + addNode(nodeNum, channel, cfg.short_name, cfg.long_name, lastHeard, (MeshtasticView::eRole)cfg.role, + cfg.public_key.size != 0, cfg.has_is_unmessagable && cfg.is_unmessagable); + } else { + updateNode(nodeNum, channel, cfg); + } +} + +/** + * @brief update node userName and image + * + * @param nodeNum + * @param ch + * @param userShort + * @param userLong + * @param lastHeard + * @param role + * @param viaMqtt + */ +// void TFTView_480x222::updateNode(uint32_t nodeNum, uint8_t ch, const char *userShort, const char *userLong, uint32_t lastHeard, +// eRole role, bool hasKey, bool viaMqtt) +void TFTView_480x222::updateNode(uint32_t nodeNum, uint8_t ch, const meshtastic_User &cfg) +{ + db.user = cfg; + auto it = nodes.find(nodeNum); + if (it != nodes.end() && it->second) { + if (it->first == ownNode) { + // update related settings buttons and store role in image user data + char buf[30]; + lv_snprintf(buf, sizeof(buf), _("User name: %s"), cfg.short_name); + lv_label_set_text(objects.basic_settings_user_label, buf); + + char buf1[30], buf2[40]; + lv_dropdown_set_selected(objects.settings_device_role_dropdown, + role2val(meshtastic_Config_DeviceConfig_Role(cfg.role))); + lv_dropdown_get_selected_str(objects.settings_device_role_dropdown, buf1, sizeof(buf1)); + lv_snprintf(buf2, sizeof(buf2), _("Device Role: %s"), buf1); + lv_label_set_text(objects.basic_settings_role_label, buf2); + + // update DB + strcpy(db.short_name, cfg.short_name); + strcpy(db.long_name, cfg.long_name); + db.config.device.role = cfg.role; + } + lv_label_set_text(it->second->LV_OBJ_IDX(node_lbl_idx), cfg.long_name); + it->second->LV_OBJ_IDX(node_lbl_idx)->user_data = (void *)nodeNum; + lv_label_set_text(it->second->LV_OBJ_IDX(node_lbs_idx), cfg.short_name); + char *userData = (char *)&(it->second->LV_OBJ_IDX(node_lbs_idx)->user_data); + userData[0] = cfg.short_name[0]; + if (userData[0] == 0x00) + userData[0] = ' '; + userData[1] = cfg.short_name[1]; + if (userData[1] == 0x00) + userData[1] = ' '; + userData[2] = cfg.short_name[2]; + if (userData[2] == 0x00) + userData[2] = ' '; + userData[3] = cfg.short_name[3]; + if (userData[3] == 0x00) + userData[3] = ' '; + + setNodeImage(nodeNum, (MeshtasticView::eRole)cfg.role, cfg.has_is_unmessagable && cfg.is_unmessagable, + it->second->LV_OBJ_IDX(node_img_idx)); + + if (cfg.public_key.size != 0) { + // set border color to bg color + lv_color_t color = lv_obj_get_style_bg_color(it->second->LV_OBJ_IDX(node_img_idx), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(it->second->LV_OBJ_IDX(node_img_idx), color, LV_PART_MAIN | LV_STATE_DEFAULT); + } else { + lv_obj_set_style_border_color(it->second->LV_OBJ_IDX(node_img_idx), colorRed, LV_PART_MAIN | LV_STATE_DEFAULT); + } + + // update chat name + auto ct = chats.find(it->first); + if (ct != chats.end()) { + char buf[64]; + lv_snprintf(buf, sizeof(buf), "%s: %s", lv_label_get_text(it->second->LV_OBJ_IDX(node_lbs_idx)), + lv_label_get_text(it->second->LV_OBJ_IDX(node_lbl_idx))); + lv_label_set_text(ct->second->spec_attr->children[0], buf); + } + } +} + +void TFTView_480x222::updatePosition(uint32_t nodeNum, int32_t lat, int32_t lon, int32_t alt, uint32_t sats, uint32_t precision) +{ + int32_t altU = abs(alt) < 10000 ? alt : 0; + char units[3] = {}; + if (db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC) { + units[0] = 'm'; + } else { + units[0] = 'f'; + units[1] = 't'; + altU = int32_t(float(altU) * 3.28084); + } + if (nodeNum == ownNode) { + char buf[64]; + int latSeconds = (int)round(lat * 1e-7 * 3600); + int latDegrees = latSeconds / 3600; + latSeconds = abs(latSeconds % 3600); + int latMinutes = latSeconds / 60; + latSeconds %= 60; + char latLetter = (lat > 0) ? 'N' : 'S'; + + int lonSeconds = (int)round(lon * 1e-7 * 3600); + int lonDegrees = lonSeconds / 3600; + lonSeconds = abs(lonSeconds % 3600); + int lonMinutes = lonSeconds / 60; + lonSeconds %= 60; + char lonLetter = (lon > 0) ? 'E' : 'W'; + + if (sats) + sprintf(buf, "%c%02i° %2i'%02i\" %u sats\n%c%02i° %2i'%02i\" %d%s", latLetter, abs(latDegrees), latMinutes, + latSeconds, sats, lonLetter, abs(lonDegrees), lonMinutes, lonSeconds, altU, units); + else + sprintf(buf, "%c%02i° %2i'%02i\"\n%c%02i° %2i'%02i\" %d%s", latLetter, abs(latDegrees), latMinutes, latSeconds, + lonLetter, abs(lonDegrees), lonMinutes, lonSeconds, altU, units); + + lv_label_set_text(objects.home_location_label, buf); + + if (lat != 0 && lon != 0) { + hasPosition = true; + myLatitude = lat; + myLongitude = lon; + + // go through existing node list and update distance + // TODO: need incremental update!? + for (auto &it : nodes) { + if (it.first != ownNode) { + int32_t nlat = (long)it.second->LV_OBJ_IDX(node_pos1_idx)->user_data; + int32_t nlon = (long)it.second->LV_OBJ_IDX(node_pos2_idx)->user_data; + if (nlat != 0 && nlon != 0) { + updateDistance(it.first, nlat, nlon); + } + } + } + // update own location on map + if (map) + map->setGpsPosition(lat * 1e-7, lon * 1e-7); + } + } else { + if (lat != 0 && lon != 0) { + if (hasPosition) { + updateDistance(nodeNum, lat, lon); + } + addOrUpdateMap(nodeNum, lat, lon); + } + } + + if (lat != 0 && lon != 0) { + char buf[32]; + sprintf(buf, "%.5f %.5f", lat * 1e-7, lon * 1e-7); + lv_obj_t *panel = nodes[nodeNum]; + lv_label_set_text(panel->LV_OBJ_IDX(node_pos1_idx), buf); + if (sats) + sprintf(buf, "%d%s MSL %u sats", altU, units, sats); + sprintf(buf, "%d%s MSL", altU, units); + lv_label_set_text(panel->LV_OBJ_IDX(node_pos2_idx), buf); + // store lat/lon in user_data, because we need these values later to calculate the distance to us + panel->LV_OBJ_IDX(node_pos1_idx)->user_data = (void *)lat; + panel->LV_OBJ_IDX(node_pos2_idx)->user_data = (void *)lon; + } + + applyNodesFilter(nodeNum); +} + +void TFTView_480x222::updateDistance(uint32_t nodeNum, int32_t lat, int32_t lon) +{ + // if we know our position then calculate (simple) distance to other node in km + float dx = 71.5 * 1e-7 * (myLongitude - lon); + float dy = 111.3 * 1e-7 * (myLatitude - lat); + float dist = sqrt(dx * dx + dy * dy); + + // add distance to user short field + char buf[32]; + char *userData = (char *)&(nodes[nodeNum]->LV_OBJ_IDX(node_lbs_idx)->user_data); + buf[0] = userData[0]; + buf[1] = userData[1]; + buf[2] = userData[2]; + buf[3] = userData[3]; + buf[4] = '\n'; + + if (db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC) { + if (dist > 1.0) + sprintf(&buf[5], "%.1f km ", dist); + else + sprintf(&buf[5], "%d m ", (uint32_t)round(dist * 1000)); + } else { + if (dist > 0.1) + sprintf(&buf[5], "%.1f mi ", round(dist * 0.621371)); + else + sprintf(&buf[5], "%d ft ", uint32_t(dist * 3280.84)); + } + // we used the userShort label to add the distance, so re-arrange a bit the position + lv_obj_t *userShort = nodes[nodeNum]->LV_OBJ_IDX(node_lbs_idx); + lv_label_set_text(userShort, buf); + lv_obj_set_pos(userShort, 30, -1); +} + +/** + * @brief Update battery level and air utilisation + * + * @param nodeNum + * @param bat_level + * @param voltage + * @param chUtil + * @param airUtil + */ +void TFTView_480x222::updateMetrics(uint32_t nodeNum, uint32_t bat_level, float voltage, float chUtil, float airUtil) +{ + auto it = nodes.find(nodeNum); + if (it != nodes.end()) { + char buf[48]; + if (it->first == ownNode) { + sprintf(buf, _("Util %0.1f%% Air %0.1f%%"), chUtil, airUtil); + lv_label_set_text(it->second->LV_OBJ_IDX(node_sig_idx), buf); + + // update battery percentage and symbol + if (bat_level != 0 || voltage != 0) { + uint32_t shown_level = std::min(bat_level, (uint32_t)100); + sprintf(buf, "%d%%", shown_level); + bool alert = false; + + BatteryLevel level; + BatteryLevel::Status status = level.calcStatus(bat_level, voltage); + switch (status) { + case BatteryLevel::Plugged: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_plug_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + if (shown_level == 100) + buf[0] = '\0'; + break; + case BatteryLevel::Charging: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_bolt_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Full: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_full_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Mid: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_mid_image, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Low: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_low_image, LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Empty: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_empty_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case BatteryLevel::Warn: + lv_obj_set_style_bg_image_src(objects.battery_image, &img_battery_empty_warn_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + buf[0] = '\0'; + alert = true; + break; + default: + ILOG_ERROR("unhandled battery level %d", status); + break; + } + Themes::recolorTopLabel(objects.battery_percentage_label, alert); + lv_obj_set_style_bg_image_recolor_opa(objects.battery_image, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_text(objects.battery_percentage_label, buf); + } + } + + if (bat_level != 0 || voltage != 0) { + bat_level = std::min(bat_level, (uint32_t)100); + sprintf(buf, "%d%% %0.2fV", bat_level, voltage); + lv_label_set_text(it->second->LV_OBJ_IDX(node_bat_idx), buf); + } + } +} + +void TFTView_480x222::updateEnvironmentMetrics(uint32_t nodeNum, const meshtastic_EnvironmentMetrics &metrics) +{ + auto it = nodes.find(nodeNum); + if (it != nodes.end()) { + char buf[50]; + if (db.config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_METRIC) { + if ((int)metrics.relative_humidity > 0) { + sprintf(buf, "%2.1f°C %d%% %3.1fhPa", metrics.temperature, (int)metrics.relative_humidity, + metrics.barometric_pressure); + } else { + sprintf(buf, "%2.1f°C %3.1fhPa", metrics.temperature, metrics.barometric_pressure); + } + } else { + if ((int)metrics.relative_humidity > 0) { + sprintf(buf, "%2.1f°F %d%% %3.1finHg", metrics.temperature * 9 / 5 + 32, (int)metrics.relative_humidity, + metrics.barometric_pressure / 33.86f); + } else { + sprintf(buf, "%2.1f°F %3.1finHg", metrics.temperature * 9 / 5 + 32, metrics.barometric_pressure / 33.86f); + } + } + lv_label_set_text(it->second->LV_OBJ_IDX(node_tm1_idx), buf); + + if (metrics.iaq > 0 && metrics.iaq < 1000) { + sprintf(buf, "IAQ: %d %.1fV %.1fmA", metrics.iaq, metrics.voltage, metrics.current); + lv_label_set_text(it->second->LV_OBJ_IDX(node_tm2_idx), buf); + it->second->LV_OBJ_IDX(node_tm2_idx)->user_data = (void *)(uint32_t)metrics.iaq; + } + applyNodesFilter(nodeNum); + } +} + +void TFTView_480x222::updateAirQualityMetrics(uint32_t nodeNum, const meshtastic_AirQualityMetrics &metrics) +{ + auto it = nodes.find(nodeNum); + if (it != nodes.end() && it->first != ownNode) { + // TODO + // char buf[32]; + // sprintf(buf, "%d %d", metrics.particles_03um, metrics.pm100_environmental); + // lv_label_set_text(it->second->LV_OBJ_IDX(node_tm2_idx), buf); + } +} + +void TFTView_480x222::updatePowerMetrics(uint32_t nodeNum, const meshtastic_PowerMetrics &metrics) +{ + auto it = nodes.find(nodeNum); + if (it != nodes.end() && it->first != ownNode) { + // TODO + // char buf[32]; + // sprintf(buf, "%0.1fmA %0.2fV", metrics.ch1_current, metrics.ch1_voltage); + // lv_label_set_text(it->second->LV_OBJ_IDX(node_tm2_idx), buf); + } +} + +/** + * update signal strength for direct neighbors + */ +void TFTView_480x222::updateSignalStrength(uint32_t nodeNum, int32_t rssi, float snr) +{ + if (nodeNum != ownNode) { + auto it = nodes.find(nodeNum); + if (it != nodes.end()) { + char buf[32]; + if (rssi == 0 && snr == 0.0) { + buf[0] = '\0'; + } else { + sprintf(buf, "rssi: %d snr: %.1f", rssi, snr); + } + lv_label_set_text(it->second->LV_OBJ_IDX(node_sig_idx), buf); + it->second->LV_OBJ_IDX(node_sig_idx)->user_data = 0; + } + } +} + +void TFTView_480x222::updateHopsAway(uint32_t nodeNum, uint8_t hopsAway) +{ + if (nodeNum != ownNode) { + auto it = nodes.find(nodeNum); + if (it != nodes.end()) { + char buf[32]; + sprintf(buf, _("hops: %d"), (int)hopsAway); + lv_label_set_text(it->second->LV_OBJ_IDX(node_sig_idx), buf); + it->second->LV_OBJ_IDX(node_sig_idx)->user_data = (void *)(unsigned long)hopsAway; + } + } +} + +void TFTView_480x222::updateConnectionStatus(const meshtastic_DeviceConnectionStatus &status) +{ + db.connectionStatus = status; + if (status.has_wifi) { + if (db.config.network.wifi_enabled || db.config.network.eth_enabled) { + if (status.wifi.has_status) { + char buf[20]; + uint32_t ip = status.wifi.status.ip_address; + sprintf(buf, "%d.%d.%d.%d", ip & 0xff, (ip & 0xff00) >> 8, (ip & 0xff0000) >> 16, (ip & 0xff000000) >> 24); + lv_label_set_text(objects.home_wlan_label, buf); + Themes::recolorButton(objects.home_wlan_button, true); + Themes::recolorText(objects.home_wlan_label, true); + if (status.wifi.status.is_connected) { + lv_obj_set_style_bg_img_src(objects.home_wlan_button, &img_home_wlan_button_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } else { + lv_obj_set_style_bg_img_src(objects.home_wlan_button, &img_home_wlan_off_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } + + if (status.wifi.status.is_mqtt_connected) { + Themes::recolorButton(objects.home_mqtt_button, true, 255); + Themes::recolorText(objects.home_mqtt_label, true); + } else { + Themes::recolorButton(objects.home_mqtt_button, db.module_config.mqtt.enabled); + Themes::recolorText(objects.home_mqtt_label, false); + } + } + } else { + Themes::recolorButton(objects.home_wlan_button, false); + Themes::recolorText(objects.home_wlan_label, false); + if (status.wifi.status.is_mqtt_connected) { + Themes::recolorButton(objects.home_mqtt_button, true, 255); + Themes::recolorText(objects.home_mqtt_label, true); + } else { + Themes::recolorButton(objects.home_mqtt_button, db.module_config.mqtt.enabled, 100); + Themes::recolorText(objects.home_mqtt_label, false); + } + lv_obj_set_style_bg_img_src(objects.home_wlan_button, &img_home_wlan_off_image, LV_PART_MAIN | LV_STATE_DEFAULT); + } + } else { + lv_obj_add_flag(objects.home_wlan_label, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.home_wlan_button, LV_OBJ_FLAG_HIDDEN); + } + + if (status.has_bluetooth) { + if (db.config.bluetooth.enabled) { + if (status.bluetooth.is_connected) { + char buf[20]; + uint32_t mac = ownNode; + lv_obj_set_style_text_color(objects.home_bluetooth_label, colorLightGray, LV_PART_MAIN | LV_STATE_DEFAULT); + sprintf(buf, "??:??:%02x:%02x:%02x:%02x", mac & 0xff, (mac & 0xff00) >> 8, (mac & 0xff0000) >> 16, + (mac & 0xff000000) >> 24); + lv_label_set_text(objects.home_bluetooth_label, buf); + lv_obj_set_style_bg_opa(objects.home_bluetooth_button, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_src(objects.home_bluetooth_button, &img_home_bluetooth_on_button_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } else { + lv_obj_set_style_text_color(objects.home_bluetooth_label, colorMidGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_src(objects.home_bluetooth_button, &img_home_bluetooth_on_button_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(objects.home_bluetooth_button, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + } + } else { + lv_obj_set_style_text_color(objects.home_bluetooth_label, colorMidGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_src(objects.home_bluetooth_button, &img_home_bluetooth_off_button_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_img_recolor_opa(objects.home_bluetooth_button, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + } + } else { + lv_obj_add_flag(objects.home_bluetooth_label, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.home_bluetooth_button, LV_OBJ_FLAG_HIDDEN); + } + + if (status.has_ethernet) { + if (status.ethernet.status.is_connected) { + char buf[20]; + uint32_t mac = ownNode; + sprintf(buf, "??:??:%02x:%02x:%02x:%02x", mac & 0xff000000, mac & 0xff0000, mac & 0xff00, mac & 0xff); + lv_label_set_text(objects.home_ethernet_label, buf); + lv_obj_set_style_text_color(objects.home_ethernet_label, colorLightGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(objects.home_ethernet_button, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + } else { + lv_obj_set_style_bg_img_recolor_opa(objects.home_ethernet_button, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_color(objects.home_ethernet_label, colorMidGray, LV_PART_MAIN | LV_STATE_DEFAULT); + } + } else { + lv_obj_add_flag(objects.home_ethernet_label, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.home_ethernet_button, LV_OBJ_FLAG_HIDDEN); + } +} + +// ResponseHandler callbacks + +void TFTView_480x222::onTextMessageCallback(const ResponseHandler::Request &req, ResponseHandler::EventType evt, int32_t result) +{ + ILOG_DEBUG("onTextMessageCallback: %d %d", evt, result); + if (evt == ResponseHandler::found) { + handleTextMessageResponse((unsigned long)req.cookie, req.id, false, result); + } else if (evt == ResponseHandler::removed) { + handleTextMessageResponse((unsigned long)req.cookie, req.id, true, result); + } else { + ILOG_DEBUG("onTextMessageCallback: timeout!"); + } +} + +void TFTView_480x222::onPositionCallback(const ResponseHandler::Request &req, ResponseHandler::EventType evt, int32_t) {} + +void TFTView_480x222::onTracerouteCallback(const ResponseHandler::Request &req, ResponseHandler::EventType evt, int32_t) {} + +/** + * handle response from routing + */ +void TFTView_480x222::handleResponse(uint32_t from, const uint32_t id, const meshtastic_Routing &routing, + const meshtastic_MeshPacket &p) +{ + ResponseHandler::Request req{}; + bool ack = false; + if (from == ownNode) { + req = requests.findRequest(id); + } else { + req = requests.removeRequest(id); + ack = true; + } + + if (req.type == ResponseHandler::noRequest) { + ILOG_WARN("request id 0x%08x not valid (anymore)", id); + } else { + ILOG_DEBUG("handleResponse request id 0x%08x", id); + } + ILOG_DEBUG("routing tag variant: %d, error: %d", routing.which_variant, routing.error_reason); + switch (routing.which_variant) { + case meshtastic_Routing_error_reason_tag: { + if (routing.error_reason == meshtastic_Routing_Error_NONE) { + if (req.type == ResponseHandler::TraceRouteRequest) { + handleTraceRouteResponse(routing); + } else if (req.type == ResponseHandler::TextMessageRequest) { + handleTextMessageResponse((unsigned long)req.cookie, id, ack, false); + } else if (req.type == ResponseHandler::PositionRequest) { + handlePositionResponse(from, id, p.rx_rssi, p.rx_snr, p.hop_limit == p.hop_start); + } + } else if (routing.error_reason == meshtastic_Routing_Error_MAX_RETRANSMIT) { + ResponseHandler::Request req = requests.removeRequest(id); + if (req.type == ResponseHandler::TraceRouteRequest) { + handleTraceRouteResponse(routing); + } else if (req.type == ResponseHandler::TextMessageRequest) { + handleTextMessageResponse((unsigned long)req.cookie, id, ack, true); + } + } else if (routing.error_reason == meshtastic_Routing_Error_NO_RESPONSE) { + if (req.type == ResponseHandler::PositionRequest) { + handlePositionResponse(from, id, p.rx_rssi, p.rx_snr, p.hop_limit == p.hop_start); + } + } else if (routing.error_reason == meshtastic_Routing_Error_NO_CHANNEL || + routing.error_reason == meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY) { + if (req.type == ResponseHandler::TextMessageRequest) { + handleTextMessageResponse((unsigned long)req.cookie, id, ack, true); + // we probably have a wrong key; mark it as bad and don't use in future + if ((unsigned long)nodes[from]->LV_OBJ_IDX(node_bat_idx)->user_data == 1) { + ILOG_DEBUG("public key mismatch"); + nodes[from]->LV_OBJ_IDX(node_bat_idx)->user_data = (void *)2; + lv_obj_set_style_border_color(nodes[from]->LV_OBJ_IDX(node_img_idx), colorRed, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_slash_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } + } + } else { + ILOG_DEBUG("got Routing_Error %d", routing.error_reason); + } + break; + } + case meshtastic_Routing_route_request_tag: { + ILOG_ERROR("got meshtastic_Routing_route_request_tag"); + break; + } + case meshtastic_Routing_route_reply_tag: { + ILOG_DEBUG("got meshtastic_Routing_route_reply_tag"); + handleResponse(from, id, routing.route_reply); + break; + } + default: + ILOG_ERROR("unhandled meshtastic_Routing tag"); + break; + } +} + +/** + * Signal scanner + */ +void TFTView_480x222::scanSignal(uint32_t scanNo) +{ + if (scans == 1 && spinnerButton) { + lv_label_set_text(objects.signal_scanner_start_label, _("Start")); + removeSpinner(); + } else { + uint32_t requestId; + uint32_t to = currentNode; + uint8_t ch = (uint8_t)(unsigned long)currentPanel->user_data; + requestId = requests.addRequest(to, ResponseHandler::PositionRequest, (void *)to); + controller->requestPosition(to, ch, requestId); + objects.signal_scanner_panel->user_data = (void *)requestId; + } +} + +void TFTView_480x222::handlePositionResponse(uint32_t from, uint32_t request_id, int32_t rx_rssi, float rx_snr, bool isNeighbor) +{ + if (request_id == (unsigned long)objects.signal_scanner_panel->user_data) { + requests.removeRequest(request_id); + + if (from == currentNode && isNeighbor) { + char buf[20]; + sprintf(buf, "SNR\n%.1f", rx_snr); + lv_label_set_text(objects.signal_scanner_snr_label, buf); + sprintf(buf, "RSSI\n%d", rx_rssi); + lv_label_set_text(objects.signal_scanner_rssi_label, buf); + lv_slider_set_value(objects.snr_slider, rx_snr, LV_ANIM_ON); + lv_slider_set_value(objects.rssi_slider, rx_rssi, LV_ANIM_ON); + sprintf(buf, "%d%%", signalStrength2Percent(rx_rssi, rx_snr)); + lv_label_set_text(objects.signal_scanner_start_label, buf); + } + } else { + ILOG_DEBUG("handlePositionResponse: drop reply with not matching request 0x%08x", request_id); + } +} + +/** + * Trace Route: handle ack or timeout + */ +void TFTView_480x222::handleTraceRouteResponse(const meshtastic_Routing &routing) +{ + ILOG_DEBUG("handleTraceRouteResponse: route has %d hops", routing.route_reply.route_count); + if (routing.error_reason != meshtastic_Routing_Error_NONE) { + lv_label_set_text(objects.trace_route_start_label, _("Start")); + removeSpinner(); + } else { + // we got a first ACK to our route request + if (spinnerButton) { + lv_obj_set_style_outline_color(objects.trace_route_start_button, lv_color_hex(0xDBD251), + LV_PART_MAIN | LV_STATE_DEFAULT); + } + } +} + +void TFTView_480x222::handleResponse(uint32_t from, uint32_t id, const meshtastic_RouteDiscovery &route) +{ + ILOG_DEBUG("handleResponse: trace route has %d / %d hops", route.route_count, route.route_back_count); + lv_obj_add_flag(objects.start_button_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(objects.hop_routes_panel, LV_OBJ_FLAG_HIDDEN); + + if (id && requests.findRequest(id).type == ResponseHandler::TraceRouteRequest) { + requests.removeRequest(id); + } + + for (int i = route.route_count; i > 0; i--) { + addNodeToTraceRoute(route.route[i - 1], objects.route_towards_panel); + } + + for (int i = 0; i < route.route_back_count; i++) { + addNodeToTraceRoute(route.route_back[i], objects.route_back_panel); + } + + // route contains only intermediate nodes, so add our node + addNodeToTraceRoute(ownNode, objects.trace_route_panel); +} + +void TFTView_480x222::addNodeToTraceRoute(uint32_t nodeNum, lv_obj_t *panel) +{ + // check if node exists, and get its panel + lv_obj_t *nodePanel = nullptr; + auto it = nodes.find(nodeNum); + if (it != nodes.end()) { + nodePanel = it->second; + } + lv_obj_t *btn = lv_btn_create(panel); + // objects.trace_route_to_button = btn; + lv_obj_set_pos(btn, 0, 0); + lv_obj_set_size(btn, LV_PCT(100), 38); + add_style_settings_button_style(btn); + lv_obj_set_style_align(btn, LV_ALIGN_TOP_MID, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(btn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(btn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(btn, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_shadow_width(btn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_shadow_ofs_y(btn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(btn, colorMidGray, LV_PART_MAIN | LV_STATE_DEFAULT); + { + { + lv_obj_t *img = lv_img_create(btn); + if (nodePanel) { + setNodeImage(nodeNum, (MeshtasticView::eRole)(unsigned long)nodePanel->LV_OBJ_IDX(node_img_idx)->user_data, false, + img); + } else { + setNodeImage(0, eRole::unknown, false, img); + } + lv_obj_set_pos(img, -5, 3); + lv_obj_set_size(img, 32, 32); + lv_obj_clear_flag(img, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(img, 3, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_align(img, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(img, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + } + { + // TraceRouteToButtonLabel + lv_obj_t *label = lv_label_create(btn); + lv_obj_set_pos(label, 35, 10); + lv_obj_set_size(label, LV_PCT(80), LV_SIZE_CONTENT); + lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL); + if (nodePanel) { + if (nodeNum != ownNode) { + lv_obj_add_event_cb(btn, ui_event_trace_route_node, LV_EVENT_CLICKED, nodePanel); + lv_label_set_text(label, lv_label_get_text(nodePanel->LV_OBJ_IDX(node_lbs_idx))); + if (strlen(lv_label_get_text(label)) >= 5) + lv_obj_set_pos(label, 35, -1); + } else { + lv_label_set_text(label, lv_label_get_text(nodePanel->LV_OBJ_IDX(node_lbl_idx))); + } + } else { + char buf[20]; + if (nodeNum != UINT32_MAX) { + lv_snprintf(buf, 16, "!%08x", nodeNum); + lv_label_set_text(label, buf); + } else + lv_label_set_text(label, _("unknown")); + } + lv_obj_set_style_align(label, LV_ALIGN_TOP_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + } + } +} + +/** + * @brief purge oldest node from node list (and all its memory) + * @param nodeNum node that is being added and already contained in nodes[], so don't remove it! + */ +void TFTView_480x222::purgeNode(uint32_t nodeNum) +{ + if (nodeCount <= 1) + return; + + lv_obj_t **children = objects.nodes_panel->spec_attr->children; + int last = objects.nodes_panel->spec_attr->child_cnt - 1; + int i = last; + +#ifndef ALWAYS_PURGE_OLDEST_NODE + time_t curr_time; +#ifdef ARCH_PORTDUINO + time(&curr_time); +#else + curr_time = actTime; +#endif + // prefer purging older unknown nodes first (but not the brand new ones) + while ((eRole)(long)(children[i]->LV_OBJ_IDX(node_img_idx)->user_data) != eRole::unknown || + curr_time < (time_t)(children[i]->LV_OBJ_IDX(node_lh_idx)->user_data) + 120 || + (unsigned long)(children[i]->LV_OBJ_IDX(node_lbl_idx)->user_data) == nodeNum || + chats.find((unsigned long)(children[i]->LV_OBJ_IDX(node_lbl_idx)->user_data)) != chats.end()) { + if (i < (last + 1) / 5) { // keep 80% named nodes and 20% unknown (not fresh) nodes + i = last; + break; + } + i--; + } +#endif + lv_obj_t *p = children[i]; + uint32_t oldest = (unsigned long)(p->LV_OBJ_IDX(node_lbl_idx)->user_data); + uint32_t lastHeard = (unsigned long)p->LV_OBJ_IDX(node_lh_idx)->user_data; + if (lastHeard > 0 && (curtime - lastHeard <= secs_until_offline)) + nodesOnline--; + + ILOG_INFO("removing oldest node 0x%08x", oldest); + lv_obj_delete(p); + { + auto it = messages.find(oldest); + if (it != messages.end()) { + lv_obj_delete(it->second); + messages.erase(oldest); + } + } + + { + auto it = chats.find(oldest); + if (it != chats.end()) { + lv_obj_delete(it->second); + chats.erase(oldest); + updateActiveChats(); + } + } + removeFromMap(oldest); + nodes.erase(oldest); + nodeCount--; + nodesChanged = true; // flag to force re-apply node filter +} + +/** + * @brief apply enabled filters and highlight node + * + * @param nodeNum + * @param reset : set true when filter has changed (to recalculate number of filtered nodes) + * @return true + * @return false + */ +bool TFTView_480x222::applyNodesFilter(uint32_t nodeNum, bool reset) +{ + lv_obj_t *panel = nodes[nodeNum]; + bool hide = false; + if (nodeNum != ownNode /* && filter.active*/) { // TODO + if (lv_obj_has_state(objects.nodes_filter_unknown_switch, LV_STATE_CHECKED)) { + if (lv_img_get_src(panel->LV_OBJ_IDX(node_img_idx)) == &img_circle_question_image) { + hide = true; + } + } + if (lv_obj_has_state(objects.nodes_filter_offline_switch, LV_STATE_CHECKED)) { + time_t lastHeard = (time_t)panel->LV_OBJ_IDX(node_lh_idx)->user_data; + if (lastHeard == 0 || curtime - lastHeard > secs_until_offline) + hide = true; + } + if (lv_obj_has_state(objects.nodes_filter_public_key_switch, LV_STATE_CHECKED)) { + bool hasKey = (unsigned long)panel->LV_OBJ_IDX(node_bat_idx)->user_data == 1; + if (!hasKey) + hide = true; + } + if (lv_dropdown_get_selected(objects.nodes_filter_channel_dropdown) != 0) { + int selected = lv_dropdown_get_selected(objects.nodes_filter_channel_dropdown); + if (selected != 0) { + uint8_t ch = (uint8_t)(unsigned long)panel->user_data; + if (selected - 1 != ch) + hide = true; + } + } + if (lv_dropdown_get_selected(objects.nodes_filter_hops_dropdown) != 0) { + int32_t hopsAway = (signed long)panel->LV_OBJ_IDX(node_sig_idx)->user_data; + int selected = lv_dropdown_get_selected(objects.nodes_filter_hops_dropdown) - 7; + if (hopsAway < 0) + hide = true; + else if (selected <= 0) { + if (hopsAway > -selected) + hide = true; + } else { + if (hopsAway < selected) + hide = true; + } + } +#if 0 + if (lv_obj_has_state(objects.nodes_filter_mqtt_switch, LV_STATE_CHECKED)) { + bool viaMqtt = false; // TODO (unsigned long)panel->LV_OBJ_IDX(node_sig_idx)->user_data; + if (viaMqtt) + hide = true; + } +#endif + if (lv_obj_has_state(objects.nodes_filter_position_switch, LV_STATE_CHECKED)) { + if (lv_label_get_text(panel->LV_OBJ_IDX(node_pos1_idx))[0] == '\0') + hide = true; + } + const char *name = lv_textarea_get_text(objects.nodes_filter_name_area); + if (name[0] != '\0') { + if (name[0] != '!') { // use '!' char to negate search result + if (!strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbl_idx)), name) && + !strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbs_idx)), name)) { + hide = true; + } + } else { + if (strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbl_idx)), &name[1]) || + strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbs_idx)), &name[1])) { + hide = true; + } + } + } + } + if (hide) { + if (reset || !lv_obj_has_flag(panel, LV_OBJ_FLAG_HIDDEN)) { + lv_obj_add_flag(panel, LV_OBJ_FLAG_HIDDEN); + nodesFiltered++; + } + } else { + lv_obj_clear_flag(panel, LV_OBJ_FLAG_HIDDEN); + } + + // hide node location if filtered + if (map) + map->update(nodeNum, hide); + + bool highlight = false; + if (true /*highlight.active*/) { // TODO + if (lv_obj_has_state(objects.nodes_hl_active_chat_switch, LV_STATE_CHECKED)) { + auto it = chats.find(nodeNum); + if (it != nodes.end()) { + lv_obj_set_style_border_color(panel, colorOrange, LV_PART_MAIN | LV_STATE_DEFAULT); + highlight = true; + } + } + if (lv_obj_has_state(objects.nodes_hl_position_switch, LV_STATE_CHECKED)) { + if (lv_label_get_text(panel->LV_OBJ_IDX(node_pos1_idx))[0] != '\0') { + lv_obj_set_style_border_color(panel, colorBlueGreen, LV_PART_MAIN | LV_STATE_DEFAULT); + highlight = true; + } + } + if (lv_obj_has_state(objects.nodes_hl_telemetry_switch, LV_STATE_CHECKED)) { + if (lv_label_get_text(panel->LV_OBJ_IDX(node_tm1_idx))[0] != '\0') { + lv_obj_set_style_border_color(panel, colorBlue, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(panel, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + highlight = true; + } + } + if (lv_obj_has_state(objects.nodes_hliaq_switch, LV_STATE_CHECKED)) { + if (lv_label_get_text(panel->LV_OBJ_IDX(node_tm2_idx))[0] != '\0') { + uint32_t iaq = (unsigned long)panel->LV_OBJ_IDX(node_tm2_idx)->user_data; + // IAQ color code + lv_color_t fg, bg; + if (iaq <= 50) { + fg = lv_color_hex(0x00000000); + bg = lv_color_hex(0x000ce810); + } else if (iaq <= 100) { + fg = lv_color_hex(0x00000000); + bg = lv_color_hex(0x00faf646); + } else if (iaq <= 150) { + fg = lv_color_hex(0x00000000); + bg = lv_color_hex(0x00f98204); + } else if (iaq <= 200) { + fg = lv_color_hex(0x00000000); + bg = lv_color_hex(0x00e42104); + } else if (iaq <= 300) { + fg = lv_color_hex(0xffffffff); + bg = lv_color_hex(0x009b2970); + } else { + fg = lv_color_hex(0xffffffff); + bg = lv_color_hex(0x001d1414); + } + lv_obj_set_style_text_color(panel->LV_OBJ_IDX(node_tm2_idx), fg, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(panel->LV_OBJ_IDX(node_tm2_idx), bg, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(panel->LV_OBJ_IDX(node_tm2_idx), 255, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(panel, bg, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(panel, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + highlight = true; + } + } + const char *name = lv_textarea_get_text(objects.nodes_hl_name_area); + if (name[0] != '\0') { + if (strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbl_idx)), name) || + strcasestr(lv_label_get_text(panel->LV_OBJ_IDX(node_lbs_idx)), name)) { + lv_obj_set_style_border_color(panel, colorMesh, LV_PART_MAIN | LV_STATE_DEFAULT); + highlight = true; + } + } + } + if (!highlight) { + lv_obj_set_style_border_color(panel, colorMidGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(panel, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + } + return hide; // TODO || filter.active; +} + +void TFTView_480x222::messageAlert(const char *alert, bool show) +{ + lv_label_set_text(objects.alert_label, alert); + if (show) { + lv_obj_clear_flag(objects.alert_panel, LV_OBJ_FLAG_HIDDEN); + // Auto-hide after 3 seconds + lv_timer_create([](lv_timer_t *timer) { + lv_obj_add_flag(objects.alert_panel, LV_OBJ_FLAG_HIDDEN); + lv_timer_delete(timer); + }, 3000, NULL); + } else { + lv_obj_add_flag(objects.alert_panel, LV_OBJ_FLAG_HIDDEN); + } +} + +/** + * @brief mark the sent message as either heard or acknowledged or failed + * + * @param channelOrNode + * @param id + * @param ack + */ +void TFTView_480x222::handleTextMessageResponse(uint32_t channelOrNode, const uint32_t id, bool ack, bool err) +{ + lv_obj_t *msgContainer; + if (channelOrNode < c_max_channels) { + msgContainer = channelGroup[(uint8_t)channelOrNode]; + ack = true; // treat messages sent to group channel same as ack + } else { + msgContainer = messages[channelOrNode]; + } + if (!msgContainer) { + ILOG_WARN("received unexpected response nodeNum/channel 0x%08x for request id 0x%08x", channelOrNode, id); + return; + } + // go through all hiddenPanels and search for requestId + uint16_t i = msgContainer->spec_attr->child_cnt; + while (i-- > 0) { + lv_obj_t *panel = msgContainer->spec_attr->children[i]; + uint32_t requestId = (unsigned long)panel->user_data; + if (requestId == id) { + // now give the textlabel border another color + lv_obj_t *textLabel = panel->spec_attr->children[0]; + lv_obj_set_style_border_color(textLabel, + err ? colorRed + : ack ? colorBlueGreen + : colorYellow, + LV_PART_MAIN | LV_STATE_DEFAULT); + + // store message + break; + } + } +} + +void TFTView_480x222::packetReceived(const meshtastic_MeshPacket &p) +{ + MeshtasticView::packetReceived(p); + + // try update time from packet + if (!VALID_TIME(actTime) && VALID_TIME(p.rx_time)) + updateTime(p.rx_time); + + if (detectorRunning) { + packetDetected(p); + } + if (packetLogEnabled) { + writePacketLog(p); + } + if (p.from != ownNode) { + updateSignalStrength(p.rx_rssi, p.rx_snr); + } + updateStatistics(p); +} + +void TFTView_480x222::notifyConnected(const char *info) +{ + if (state == MeshtasticView::eBooting) { + updateBootMessage(info); + } else { + if (state == MeshtasticView::eDisconnected) { + messageAlert(_("Connected!"), true); + // force re-sync with node + THIS->controller->setConfigRequested(true); + } + state = MeshtasticView::eRunning; + } +} + +void TFTView_480x222::notifyDisconnected(const char *info) +{ + if (state == MeshtasticView::eBooting) { + updateBootMessage(info); + } else { + if (state == MeshtasticView::eRunning) { + messageAlert(_("Disconnected!"), true); + } + state = MeshtasticView::eDisconnected; + } +} + +void TFTView_480x222::notifyResync(bool show) +{ + if (controller->isStandalone()) { + if (show) + notifyReboot(true); + } else { + messageAlert(_("Resync ..."), show); + if (!show) { + lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + } + } +} + +void TFTView_480x222::notifyReboot(bool show) +{ + messageAlert(_("Rebooting ..."), show); + if (controller->isStandalone()) { + lv_timer_create(timer_event_reboot, 8000, NULL); + } +} + +void TFTView_480x222::notifyShutdown(void) +{ + messageAlert(_("Shutting down ..."), true); +} + +void TFTView_480x222::blankScreen(bool enable) +{ + ILOG_DEBUG("%s screen (%s)", enable ? "blank" : "unblank", screenLocked ? "locked" : "timeout"); + if (enable) + lv_screen_load_anim(objects.blank_screen, LV_SCR_LOAD_ANIM_FADE_OUT, 1000, 0, false); + else { + if (objects.main_screen) + lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + else + lv_screen_load_anim(objects.boot_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + } +} + +void TFTView_480x222::screenSaving(bool enabled) +{ + if (enabled) { + // overlay main screen with blank screen to prevent accidentally pressing buttons + lv_screen_load_anim(objects.blank_screen, LV_SCR_LOAD_ANIM_FADE_OUT, 0, 0, false); + lv_group_focus_obj(objects.blank_screen_button); + screenLocked = true; + screenUnlockRequest = false; + } else { + if (THIS->db.uiConfig.screen_lock) { + ILOG_DEBUG("showing lock screen"); + lv_screen_load_anim(objects.lock_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + } else if (objects.main_screen) { + ILOG_DEBUG("showing main screen"); + lv_screen_load_anim(objects.main_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + if (THIS->activeSettings != eNone) { + lv_event_t e = {.code = LV_EVENT_CLICKED}; + ui_event_cancel(&e); + } + screenLocked = false; + } else { + ILOG_DEBUG("showing boot screen"); + lv_screen_load_anim(objects.boot_screen, LV_SCR_LOAD_ANIM_NONE, 0, 0, false); + screenLocked = false; + } + } +} + +bool TFTView_480x222::isScreenLocked(void) +{ + return screenLocked && !screenUnlockRequest; +} + +void TFTView_480x222::updateChannelConfig(const meshtastic_Channel &ch) +{ + static lv_obj_t *btn[c_max_channels] = {objects.channel_button0, objects.channel_button1, objects.channel_button2, + objects.channel_button3, objects.channel_button4, objects.channel_button5, + objects.channel_button6, objects.channel_button7}; + db.channel[ch.index] = ch; + + if (ch.role != meshtastic_Channel_Role_DISABLED) { + setChannelName(ch); + + lv_obj_set_width(btn[ch.index], lv_pct(70)); + lv_obj_set_style_pad_left(btn[ch.index], 8, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *lockImage = NULL; + if (lv_obj_get_child_cnt(btn[ch.index]) == 1) + lockImage = lv_img_create(btn[ch.index]); + else + lockImage = lv_obj_get_child(btn[ch.index], 1); + + uint32_t recolor = 0; + + if (memcmp(ch.settings.psk.bytes, "\001\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000", 16) == 0) { + lv_image_set_src(lockImage, &img_groups_key_image); + recolor = 0xF2E459; // yellow + } else if (memcmp(ch.settings.psk.bytes, "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000", 16) == 0) { + lv_image_set_src(lockImage, &img_groups_unlock_image); + recolor = 0xF72B2B; // reddish + } else { + lv_image_set_src(lockImage, &img_groups_lock_image); + recolor = 0x1EC174; // green + } + lv_obj_set_width(lockImage, LV_SIZE_CONTENT); /// 1 + lv_obj_set_height(lockImage, LV_SIZE_CONTENT); /// 1 + lv_obj_set_align(lockImage, LV_ALIGN_LEFT_MID); + lv_obj_add_flag(lockImage, LV_OBJ_FLAG_ADV_HITTEST); /// Flags + lv_obj_clear_flag(lockImage, LV_OBJ_FLAG_SCROLLABLE); /// Flags + lv_obj_set_style_img_recolor(lockImage, lv_color_hex(recolor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_img_recolor_opa(lockImage, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *bellImage = NULL; + if (lv_obj_get_child_cnt(btn[ch.index]) < 3) + bellImage = lv_img_create(btn[ch.index]); + else + bellImage = lv_obj_get_child(btn[ch.index], 2); + lv_obj_set_width(bellImage, LV_SIZE_CONTENT); /// 1 + lv_obj_set_height(bellImage, LV_SIZE_CONTENT); /// 1 + lv_obj_set_align(bellImage, LV_ALIGN_RIGHT_MID); + lv_obj_add_flag(bellImage, LV_OBJ_FLAG_ADV_HITTEST); /// Flags + lv_obj_clear_flag(bellImage, LV_OBJ_FLAG_SCROLLABLE); /// Flags + lv_obj_set_style_img_recolor_opa(bellImage, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + updateGroupChannel(ch.index); + } else { + // display smaller button with just the channel number + char buf[10]; + lv_snprintf(buf, sizeof(buf), "%d", ch.index); + lv_label_set_text(channel[ch.index], buf); + lv_obj_set_width(btn[ch.index], lv_pct(30)); + + if (lv_obj_get_child_cnt(btn[ch.index]) == 2) { + lv_obj_delete(lv_obj_get_child(btn[ch.index], 1)); + } + } +} + +// redraw bell icons and color +void TFTView_480x222::updateGroupChannel(uint8_t chId) +{ + static lv_obj_t *btn[c_max_channels] = {objects.channel_button0, objects.channel_button1, objects.channel_button2, + objects.channel_button3, objects.channel_button4, objects.channel_button5, + objects.channel_button6, objects.channel_button7}; + + lv_obj_t *bellImage = lv_obj_get_child(btn[chId], 2); + if (db.channel[chId].settings.module_settings.is_muted) { + lv_obj_set_style_img_recolor(bellImage, lv_color_hex(0xffab0000), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_image_set_src(bellImage, &img_groups_bell_slash_image); + } else { + Themes::recolorImage(bellImage, true); + lv_image_set_src(bellImage, &img_groups_bell_image); + } +} + +void TFTView_480x222::updateDeviceConfig(const meshtastic_Config_DeviceConfig &cfg) +{ + db.config.device = cfg; + db.config.has_device = true; + + char buf1[30], buf2[40]; + lv_dropdown_set_selected(objects.settings_device_role_dropdown, role2val(cfg.role)); + lv_dropdown_get_selected_str(objects.settings_device_role_dropdown, buf1, sizeof(buf1)); + lv_snprintf(buf2, sizeof(buf2), _("Device Role: %s"), buf1); + lv_label_set_text(objects.basic_settings_role_label, buf2); +} + +void TFTView_480x222::updatePositionConfig(const meshtastic_Config_PositionConfig &cfg) +{ + db.config.position = cfg; + db.config.has_position = true; + if (cfg.gps_mode != meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) { + if (cfg.fixed_position && db.uiConfig.map_data.has_home) { + updatePosition(ownNode, db.uiConfig.map_data.home.latitude, db.uiConfig.map_data.home.longitude, 0, 0, 0); + } + // grey out text to indicate it's a fixed position vs. actual GPS position + Themes::recolorText(objects.home_location_label, !cfg.fixed_position); + } + Themes::recolorButton(objects.home_location_button, cfg.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED); +} + +void TFTView_480x222::updatePowerConfig(const meshtastic_Config_PowerConfig &cfg) +{ + db.config.power = cfg; + db.config.has_power = true; +} + +void TFTView_480x222::updateNetworkConfig(const meshtastic_Config_NetworkConfig &cfg) +{ + db.config.network = cfg; + db.config.has_network = true; + + char buf[40]; + lv_snprintf(buf, sizeof(buf), _("WiFi: %s"), cfg.wifi_ssid[0] ? cfg.wifi_ssid : _("")); + lv_label_set_text(objects.basic_settings_wifi_label, buf); +} + +void TFTView_480x222::updateDisplayConfig(const meshtastic_Config_DisplayConfig &cfg) +{ + db.config.display = cfg; + db.config.has_display = true; + if (!controller->isStandalone() && cfg.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + meshtastic_Config_DisplayConfig &display = db.config.display; + display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR; + THIS->controller->sendConfig(meshtastic_Config_DisplayConfig{display}, THIS->ownNode); + } +} + +void TFTView_480x222::updateLoRaConfig(const meshtastic_Config_LoRaConfig &cfg) +{ + db.config.lora = cfg; + db.config.has_lora = true; + + if (cfg.use_preset) { + // This must be run before displaying LoRa frequency as channel of 0 ("calculate from hash") leads to an integer underflow + if (!db.config.lora.channel_num) { + db.config.lora.channel_num = LoRaPresets::getDefaultSlot(db.config.lora.region, THIS->db.config.lora.modem_preset, + THIS->db.channel[0].settings.name); + } + char buf1[20], buf2[32]; + lv_dropdown_set_selected(objects.settings_modem_preset_dropdown, preset2val(cfg.modem_preset)); + lv_dropdown_get_selected_str(objects.settings_modem_preset_dropdown, buf1, sizeof(buf1)); + lv_snprintf(buf2, sizeof(buf2), _("Modem Preset: %s"), buf1); + lv_label_set_text(objects.basic_settings_modem_preset_label, buf2); + + uint32_t numChannels = LoRaPresets::getNumChannels(cfg.region, cfg.modem_preset); + lv_slider_set_range(objects.frequency_slot_slider, 1, numChannels); + lv_slider_set_value(objects.frequency_slot_slider, db.config.lora.channel_num, LV_ANIM_OFF); + } else { + lv_label_set_text(objects.basic_settings_modem_preset_label, _("Modem Preset: custom")); + } + + char region[30]; + lv_snprintf(region, sizeof(region), _("Region: %s"), LoRaPresets::loRaRegionToString(cfg.region)); + lv_label_set_text(objects.basic_settings_region_label, region); + + showLoRaFrequency(db.config.lora); + + if (db.config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + // update channel names again now that region is known + for (int i = 0; i < c_max_channels; i++) { + if (db.channel[i].has_settings && db.channel[i].role != meshtastic_Channel_Role_DISABLED) { + setChannelName(db.channel[i]); + } + } + } else { + requestSetup(); + } +} + +void TFTView_480x222::showLoRaFrequency(const meshtastic_Config_LoRaConfig &cfg) +{ + char loraFreq[48]; + if (!cfg.region) { + strcpy(loraFreq, _("region unset")); + } else if (cfg.use_preset) { + float frequency = LoRaPresets::getRadioFreq(cfg.region, cfg.modem_preset, cfg.channel_num) + cfg.frequency_offset; + sprintf(loraFreq, "LoRa %g MHz\n[%s kHz]", frequency, LoRaPresets::getBandwidthString(cfg.modem_preset)); + lv_obj_remove_state(objects.basic_settings_modem_preset_button, LV_STATE_DISABLED); + } else { + float frequency = cfg.override_frequency + cfg.frequency_offset; + sprintf(loraFreq, "LoRa %g MHz\n[%d kHz]", frequency, cfg.bandwidth); + lv_obj_add_state(objects.basic_settings_modem_preset_button, LV_STATE_DISABLED); + } + + lv_label_set_text(objects.home_lora_label, loraFreq); + Themes::recolorButton(objects.home_lora_button, cfg.tx_enabled); + Themes::recolorText(objects.home_lora_label, cfg.tx_enabled); + if (!cfg.tx_enabled) { + lv_obj_clear_flag(objects.top_lora_tx_panel, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(objects.top_lora_tx_panel, LV_OBJ_FLAG_HIDDEN); + } +} + +void TFTView_480x222::setBellText(bool banner, bool sound) +{ + if (banner && sound) { + lv_label_set_text(objects.home_bell_label, _("Banner & Sound")); + } else if (banner) { + lv_label_set_text(objects.home_bell_label, _("Banner only")); + } else if (sound) { + lv_label_set_text(objects.home_bell_label, _("Sound only")); + } else { + lv_label_set_text(objects.home_bell_label, _("silent")); + } + + char buf[40]; + lv_snprintf(buf, sizeof(buf), _("Message Alert: %s"), + db.module_config.external_notification.alert_message_buzzer + ? (!sound ? _("silent") : ringtone[db.uiConfig.ring_tone_id].name) + : "off"); + lv_label_set_text(objects.basic_settings_alert_label, buf); + + Themes::recolorButton(objects.home_bell_button, banner || sound); + Themes::recolorText(objects.home_bell_label, banner || sound); +} + +/** + * auto set primary(secondary) channel name (based on region) + */ +void TFTView_480x222::setChannelName(const meshtastic_Channel &ch) +{ + char buf[40]; + if (ch.role == meshtastic_Channel_Role_PRIMARY) { + sprintf(buf, _("Channel: %s"), + strlen(ch.settings.name) ? ch.settings.name + : db.config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET + ? ("") + : LoRaPresets::modemPresetToString(db.config.lora.modem_preset)); + lv_label_set_text(objects.basic_settings_channel_label, buf); + + sprintf(buf, "*%s", + strlen(ch.settings.name) ? ch.settings.name + : db.config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET + ? ("") + : LoRaPresets::modemPresetToString(db.config.lora.modem_preset)); + } else { + if (ch.settings.name[0] == '\0' && ch.settings.psk.size == 1 && ch.settings.psk.bytes[0] == 0x01) { + sprintf(buf, "%s", LoRaPresets::modemPresetToString(db.config.lora.modem_preset)); + } else { + strcpy(buf, ch.settings.name); + } + } + + lv_label_set_text(channel[ch.index], buf); + + // rename chat + auto it = chats.find(ch.index); + if (it != chats.end()) { + char buf2[64]; + sprintf(buf2, "%d: %s", (int)ch.index, buf); + lv_label_set_text(it->second->spec_attr->children[0], buf2); + } +} + +void TFTView_480x222::backup(uint32_t option) +{ +#if defined(HAS_SDCARD) || defined(HAS_SD_MMC) || defined(ARCH_PORTDUINO) + meshtastic_Config_SecurityConfig_public_key_t &pubkey = db.config.security.public_key; + meshtastic_Config_SecurityConfig_private_key_t &privkey = db.config.security.private_key; + + std::stringstream path; + path << "/keys/" << std::hex << std::setw(8) << std::setfill('0') << ownNode << ".yml"; +#if defined(ARCH_PORTDUINO) || defined(HAS_SD_MMC) + SDFs.mkdir("/keys"); + File sd = SDFs.open(path.str().c_str(), FILE_WRITE); +#else + SDFs.mkdir("/keys"); + FsFile sd = SDFs.open(path.str().c_str(), O_RDWR | O_CREAT); +#endif + if (sd) { + sd.println("config:"); + sd.println(" security:"); + sd.print(" privateKey: base64:"); + sd.println(pskToBase64(privkey.bytes, privkey.size).c_str()); + sd.print(" publicKey: base64:"); + sd.println(pskToBase64(pubkey.bytes, pubkey.size).c_str()); + ILOG_INFO("backup pub/priv keys done."); + } else { + ILOG_ERROR("open file %s for backup failed", path.str().c_str()); + messageAlert(_("Failed to write keys!"), true); + } + sd.close(); +#endif +} + +void TFTView_480x222::restore(uint32_t option) +{ +#if defined(HAS_SDCARD) || defined(HAS_SD_MMC) || defined(ARCH_PORTDUINO) + meshtastic_Config_SecurityConfig_public_key_t &pubkey = db.config.security.public_key; + meshtastic_Config_SecurityConfig_private_key_t &privkey = db.config.security.private_key; + + std::stringstream path; + path << "/keys/" << std::hex << std::setw(8) << std::setfill('0') << ownNode << ".yml"; + +#if defined(ARCH_PORTDUINO) || defined(HAS_SD_MMC) + File sd = SDFs.open(path.str().c_str(), FILE_READ); +#else + FsFile sd = SDFs.open(path.str().c_str(), O_RDONLY); +#endif + if (sd) { + // TODO: improve parsing file contents + sd.readStringUntil('\n'); // config: + sd.readStringUntil('\n'); // security: + String privKey = sd.readStringUntil('\n'); // privateKey: base64: + String pubKey = sd.readStringUntil('\n'); // publicKey: base64: + if (privKey.indexOf("privateKey:") > 0 && pubKey.indexOf("publicKey:") > 0) { + String b64priv = privKey.substring(privKey.lastIndexOf(":") + 1); + String b64pub = pubKey.substring(pubKey.lastIndexOf(":") + 1); + b64priv.trim(); + b64pub.trim(); + if (base64ToPsk(b64priv.c_str(), privkey.bytes, privkey.size) && + base64ToPsk(b64pub.c_str(), pubkey.bytes, pubkey.size) && + controller->sendConfig(meshtastic_Config_SecurityConfig{db.config.security})) { + ILOG_INFO("restore pub/priv keys sent to radio"); + } else { + ILOG_ERROR("decoding keys failed"); + messageAlert(_("Failed to restore keys!"), true); + } + } else { + ILOG_ERROR("file %s contents don't match backup", path.str().c_str()); + messageAlert(_("Failed to parse keys!"), true); + } + } else { + ILOG_ERROR("open file %s failed", path.str().c_str()); + messageAlert(_("Failed to retrieve keys!"), true); + } + sd.close(); +#endif +} + +/** + * @brief write local time stamp into buffer + * if date is not current also add day/month + * Note: time string ends with linefeed + * + * @param buf allocated buffer + * @param datetime date/time to write + * @param update update with actual time, otherwise using time from parameter 'time' + * @return length of time string + */ +uint32_t TFTView_480x222::timestamp(char *buf, uint32_t datetime, bool update) +{ + time_t local = datetime; + if (update) { +#ifdef ARCH_PORTDUINO + time(&local); +#else + if (VALID_TIME(actTime)) + local = actTime; +#endif + } + if (VALID_TIME(local)) { + std::tm date_tm{}; + localtime_r(&local, &date_tm); + if (!update) + return strftime(buf, 20, "%y/%m/%d %R\n", &date_tm); + else + return strftime(buf, 20, "%R\n", &date_tm); + } else + return 0; +} + +/** + * calculate percentage value from rssi and snr + * Note: ranges are based on the axis values of the signal scanner + */ +int32_t TFTView_480x222::signalStrength2Percent(int32_t rx_rssi, float rx_snr) +{ +#if defined(USE_SX127x) + int p_snr = ((std::max(rx_snr, -19.0f) + 19.0f) / 33.0f) * 100.0f; // range -19..14 + int p_rssi = ((std::max(rx_rssi, -145L) + 145) * 100) / 90; // range -145..-55 +#else + int p_snr = ((std::max(rx_snr, -18.0f) + 18.0f) / 26.0f) * 100.0f; // range -18..8 + int p_rssi = ((std::max(rx_rssi, -125) + 125) * 100) / 100; // range -125..-25 +#endif + return std::min((p_snr + p_rssi * 2) / 3, 100); +} + +void TFTView_480x222::updateBluetoothConfig(const meshtastic_Config_BluetoothConfig &cfg, uint32_t id) +{ + db.config.bluetooth = cfg; + db.config.has_bluetooth = true; + + if (ownNode == 0) { + ownNode = id; + } + + if (state <= MeshtasticView::eBootScreenDone && state != MeshtasticView::eWaitingForReboot) { + enterProgrammingMode(); + } +} + +void TFTView_480x222::updateSecurityConfig(const meshtastic_Config_SecurityConfig &cfg) +{ + db.config.security = cfg; + db.config.has_security = true; + + // display public key in qr code label + char buf[64]; + lv_snprintf(buf, sizeof(buf), "%s", pskToBase64((uint8_t *)cfg.public_key.bytes, cfg.public_key.size).c_str()); + lv_label_set_text(objects.home_qr_label, buf); +} + +void TFTView_480x222::updateSessionKeyConfig(const meshtastic_Config_SessionkeyConfig &cfg) +{ + // TODO +} + +/// ---- module updates ---- + +void TFTView_480x222::updateMQTTModule(const meshtastic_ModuleConfig_MQTTConfig &cfg) +{ + db.module_config.mqtt = cfg; + db.module_config.has_mqtt = true; + + char buf[32]; + lv_snprintf(buf, sizeof(buf), "%s", db.module_config.mqtt.root); + lv_label_set_text(objects.home_mqtt_label, buf); + + if (!db.module_config.mqtt.enabled) { + Themes::recolorButton(objects.home_mqtt_button, false); + Themes::recolorText(objects.home_mqtt_label, false); + } +} + +void TFTView_480x222::updateExtNotificationModule(const meshtastic_ModuleConfig_ExternalNotificationConfig &cfg) +{ + db.module_config.external_notification = cfg; + db.module_config.has_external_notification = true; + + char buf[32]; + lv_snprintf(buf, sizeof(buf), _("Message Alert: %s"), + db.module_config.external_notification.alert_message_buzzer && db.module_config.external_notification.enabled + ? _("on") + : _("off")); + lv_label_set_text(objects.basic_settings_alert_label, buf); +} + +void TFTView_480x222::updateRingtone(const char rtttl[231]) +{ + // retrieving ringtone index for dropdown + uint16_t rtIndex = 0; + for (int i = 0; i < numRingtones; i++) { + if (strncmp(ringtone[i].rtttl, rtttl, 16) == 0) { + rtIndex = i; + break; + } + } + if (rtIndex != 0) + db.uiConfig.ring_tone_id = rtIndex; + if (db.uiConfig.ring_tone_id == 0) + db.uiConfig.ring_tone_id = 1; + + // update home panel bell text + setBellText(db.uiConfig.alert_enabled, !db.silent); + bool off = !db.uiConfig.alert_enabled && db.silent; + Themes::recolorButton(objects.home_bell_button, !off); + Themes::recolorText(objects.home_bell_label, !off); + objects.home_bell_button->user_data = (void *)off; +} + +void TFTView_480x222::updateTime(uint32_t timeVal) +{ + time_t localtime; + time(&localtime); + + if (VALID_TIME(localtime)) { + if (actTime != localtime) { + ILOG_DEBUG("update (local)time: %d -> %d", actTime, localtime); + actTime = localtime; + } + } else { + if (timeVal > actTime) { + ILOG_DEBUG("update (act)time: %d -> %d", actTime, timeVal); + actTime = timeVal; + } + } +} + +/** + * @brief Create a new container for a node or group channel if it does not exist + * + * @param from + * @param to: UINT32_MAX for broadcast, ownNode (= us) otherwise + * @param channel + */ +lv_obj_t *TFTView_480x222::newMessageContainer(uint32_t from, uint32_t to, uint8_t ch) +{ + if (to == UINT32_MAX || from == 0) { + if (channelGroup[ch] != nullptr) + return channelGroup[ch]; + } else { + auto it = messages.find(from); + if (it != messages.end() && it->second) + return it->second; + } + + // create container for new messages + lv_obj_t *container = lv_obj_create(objects.messages_panel); + lv_obj_remove_style_all(container); + lv_obj_set_width(container, lv_pct(100)); + lv_obj_set_height(container, lv_pct(88)); + lv_obj_set_x(container, 0); + lv_obj_set_y(container, 0); + lv_obj_set_align(container, LV_ALIGN_TOP_MID); + lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_clear_flag(container, lv_obj_flag_t(LV_OBJ_FLAG_PRESS_LOCK | LV_OBJ_FLAG_GESTURE_BUBBLE | + LV_OBJ_FLAG_SNAPPABLE | LV_OBJ_FLAG_SCROLL_ELASTIC | + LV_OBJ_FLAG_CLICK_FOCUSABLE)); /// Flags + lv_obj_set_scrollbar_mode(container, LV_SCROLLBAR_MODE_ACTIVE); + lv_obj_set_scroll_dir(container, LV_DIR_VER); + lv_obj_set_style_pad_left(container, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(container, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(container, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(container, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(container, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(container, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + + // store new message container + if (to == UINT32_MAX || from == 0) { + channelGroup[ch] = container; + } else { + messages[from] = container; + } + + // optionally add chat to chatPanel to access the container + addChat(from, to, ch); + + return container; +} + +/** + * @brief insert a mew message that arrived into a or container + * + * @param from source node + * @param to destination node + * @param ch channel + * @param size length of msg + * @param msg text message + * @param time in/out: message time (maybe overwritten when 0) + * @param restore if restoring then skip banners and highlight + */ +void TFTView_480x222::newMessage(uint32_t from, uint32_t to, uint8_t ch, const char *msg, uint32_t &msgTime, bool restore) +{ + ILOG_DEBUG("newMessage: from:0x%08x, to:0x%08x, ch:%d, time:%d", from, to, ch, msgTime); + int pos = 0; + char buf[284]; // 237 + 4 + 40 + 2 + 1 + lv_obj_t *container = nullptr; + if (to == UINT32_MAX) { // message for group, prepend short name to msg + if (nodes.find(from) == nodes.end()) { + pos += sprintf(buf, "%04x ", from & 0xffff); + } else { + // original short name is held in userData, extract it and add msg + char *userData = (char *)&(nodes[from]->LV_OBJ_IDX(node_lbs_idx)->user_data); + while (pos < 4 && userData[pos] != 0) { + buf[pos] = userData[pos]; + pos++; + } + } + buf[pos++] = ' '; + container = channelGroup[ch]; + } else { // message for us + container = messages[from]; + } + + // if it's the first message we need a container + if (!container) { + container = newMessageContainer(from, to, ch); + } + + pos += timestamp(&buf[pos], msgTime, !restore); + sprintf(&buf[pos], "%s", msg); + + // place message into container + newMessage(from, container, ch, buf); + + if (!restore) { + // display msg popup if not already viewing the messages + if (container != activeMsgContainer || activePanel != objects.messages_panel) { + unreadMessages++; + updateUnreadMessages(); + if (activePanel != objects.messages_panel && db.uiConfig.alert_enabled && + !db.channel[ch].settings.module_settings.is_muted) { + showMessagePopup(from, to, ch, lv_label_get_text(nodes[from]->LV_OBJ_IDX(node_lbl_idx))); + } + lv_obj_add_flag(container, LV_OBJ_FLAG_HIDDEN); + } + if (container != activeMsgContainer) + highlightChat(from, to, ch); + } else { + if (container != activeMsgContainer) + lv_obj_add_flag(container, LV_OBJ_FLAG_HIDDEN); + } +} + +/** + * @brief Display message bubble in related message container + * + * @param nodeNum + * @param container + * @param ch + * @param msg + */ +void TFTView_480x222::newMessage(uint32_t nodeNum, lv_obj_t *container, uint8_t ch, const char *msg) +{ + lv_obj_t *hiddenPanel = lv_obj_create(container); + lv_obj_set_width(hiddenPanel, lv_pct(100)); + lv_obj_set_height(hiddenPanel, LV_SIZE_CONTENT); /// 50 + lv_obj_set_align(hiddenPanel, LV_ALIGN_CENTER); + lv_obj_clear_flag(hiddenPanel, LV_OBJ_FLAG_SCROLLABLE); /// Flags + lv_obj_set_style_radius(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + add_style_panel_style(hiddenPanel); + lv_obj_set_style_pad_left(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *msgLabel = lv_label_create(hiddenPanel); + // calculate expected size of text bubble, to make it look nicer + lv_coord_t width = lv_txt_get_width(msg, strlen(msg), &ui_font_montserrat_14, 0); + lv_obj_set_width(msgLabel, std::max(std::min((int32_t)(width), 160) + 10, 40)); + lv_obj_set_height(msgLabel, LV_SIZE_CONTENT); + lv_obj_set_align(msgLabel, LV_ALIGN_LEFT_MID); + lv_label_set_text(msgLabel, msg); + add_style_new_message_style(msgLabel); + + if (state == MeshtasticView::eRunning) { + lv_obj_scroll_to_view(hiddenPanel, LV_ANIM_ON); + lv_obj_move_foreground(objects.message_input_area); + } + lv_obj_add_event_cb(hiddenPanel, ui_event_chatNodeButton, LV_EVENT_CLICKED, (void *)nodeNum); +} + +/** + * restore messages from persistent log + */ +void TFTView_480x222::restoreMessage(const LogMessage &msg) +{ + //((uint8_t *)msg.bytes)[msg._size] = 0; + // ILOG_DEBUG("restoring msg from:0x%08x, to:0x%08x, ch:%d, time:%d, status:%d, trash:%d, size:%d, '%s'", msg.from, msg.to, + // msg.ch, msg.time, (int)msg.status, msg.trashFlag, msg._size, msg.bytes); + + if (msg.from == ownNode) { + lv_obj_t *container = nullptr; + if (msg.to == UINT32_MAX) { + if (msg.trashFlag && chats.find(msg.ch) != chats.end()) { + ILOG_DEBUG("trashFlag set for channel %d", msg.ch); + eraseChat(msg.ch); + return; + } else { + container = newMessageContainer(msg.from, msg.to, msg.ch); + } + } else { + if (nodes.find(msg.to) != nodes.end()) { + if (msg.trashFlag && chats.find(msg.to) != chats.end()) { + ILOG_DEBUG("trashFlag set for node %08x", msg.to); + eraseChat(msg.to); + return; + } else { + container = newMessageContainer(msg.to, msg.from, msg.ch); + } + } else { + ILOG_DEBUG("to node 0x%08x not in db", msg.to); + MeshtasticView::addOrUpdateNode(msg.to, msg.ch, 0, eRole::unknown, false, false); + } + } + if (container) { + if (container != activeMsgContainer) + lv_obj_add_flag(container, LV_OBJ_FLAG_HIDDEN); + addMessage(container, msg.time, 0, (char *)msg.bytes, msg.status); + } + } else if (nodes.find(msg.from) != nodes.end()) { + if (msg.trashFlag && chats.find(msg.from) != chats.end()) { + ILOG_DEBUG("trashFlag set for node %08x", msg.from); + eraseChat(msg.from); + return; + } else { + uint32_t time = msg.time ? msg.time : UINT32_MAX; // don't overwrite 0 with actual time + newMessage(msg.from, msg.to, msg.ch, (const char *)msg.bytes, time); + } + } else { + int pos = 0; + char buf[284]; // 237 + 4 + 40 + 2 + 1 + if (msg.to != UINT32_MAX) { + // from node not in db + ILOG_DEBUG("from node 0x%08x not in db", msg.from); + MeshtasticView::addOrUpdateNode(msg.from, msg.ch, 0, eRole::unknown, false, false); + } else { + ILOG_DEBUG("from node 0x%08x not in db and no need to insert", msg.from); + pos += sprintf(buf, "%04x ", msg.from & 0xffff); + } + uint32_t len = timestamp(buf + pos, msg.time, false); + memcpy(buf + pos + len, msg.bytes, msg.length()); + buf[pos + len + msg.length()] = 0; + + lv_obj_t *container = newMessageContainer(msg.from, msg.to, msg.ch); + lv_obj_add_flag(container, LV_OBJ_FLAG_HIDDEN); + newMessage(msg.from, container, msg.ch, buf); + } +} + +/** + * @brief Add a new chat to the chat panel to access the message container + * + * @param from + * @param to + * @param ch + */ +void TFTView_480x222::addChat(uint32_t from, uint32_t to, uint8_t ch) +{ + uint32_t index = ((to == UINT32_MAX || from == 0) ? ch : from); + auto it = chats.find(index); + if (it != chats.end()) + return; + + lv_obj_t *chatDelBtn = nullptr; + lv_obj_t *parent_obj = objects.chats_panel; + + // ChatsButton + lv_obj_t *chatBtn = lv_btn_create(parent_obj); + lv_obj_set_pos(chatBtn, 0, 0); + lv_obj_set_size(chatBtn, LV_PCT(100), buttonSize); + lv_obj_add_flag(chatBtn, LV_OBJ_FLAG_SCROLL_ON_FOCUS); + lv_obj_clear_flag(chatBtn, LV_OBJ_FLAG_SCROLLABLE); + add_style_home_button_style(chatBtn); + lv_obj_set_style_align(chatBtn, LV_ALIGN_TOP_MID, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(chatBtn, colorMidGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(chatBtn, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_shadow_ofs_x(chatBtn, 1, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_shadow_ofs_y(chatBtn, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(chatBtn, 6, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(chatBtn, 3, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(chatBtn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(chatBtn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(chatBtn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(chatBtn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(chatBtn, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_move_to_index(chatBtn, 0); + + char buf[64]; + if (to == UINT32_MAX || from == 0) { + sprintf(buf, "%d: %s", (int)ch, lv_label_get_text(channel[ch])); + } else { + auto it = nodes.find(from); + if (it != nodes.end()) { + sprintf(buf, "%s: %s", lv_label_get_text(it->second->LV_OBJ_IDX(node_lbs_idx)), + lv_label_get_text(it->second->LV_OBJ_IDX(node_lbl_idx))); + } else { + sprintf(buf, "!%08x", from); + } + } + + { + lv_obj_t *parent_obj = chatBtn; + { + // ChatsButtonLabel + lv_obj_t *obj = lv_label_create(parent_obj); + objects.chats_button_label = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_label_set_long_mode(obj, LV_LABEL_LONG_DOT); + lv_label_set_text(obj, buf); + lv_obj_set_style_align(obj, LV_ALIGN_LEFT_MID, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + } + { + // ChatDelButton + lv_obj_t *obj = lv_btn_create(parent_obj); + chatDelBtn = obj; + lv_obj_set_pos(obj, -3, -1); + lv_obj_set_size(obj, 40, 23); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_align(obj, LV_ALIGN_RIGHT_MID, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(obj, colorDarkRed, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + { + lv_obj_t *parent_obj = obj; + { + // DelLabel + lv_obj_t *chatDelBtn = lv_label_create(parent_obj); + lv_obj_set_pos(chatDelBtn, 0, 0); + lv_obj_set_size(chatDelBtn, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_label_set_text(chatDelBtn, _("DEL")); + lv_obj_set_style_align(chatDelBtn, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + } + } + } + } + + chats[index] = chatBtn; + updateActiveChats(); + if (index > c_max_channels) { + if (nodes.find(index) != nodes.end()) + applyNodesFilter(index); + } + + lv_obj_add_event_cb(chatBtn, ui_event_ChatButton, LV_EVENT_ALL, (void *)index); + lv_obj_add_event_cb(chatDelBtn, ui_event_ChatDelButton, LV_EVENT_CLICKED, (void *)index); +} + +void TFTView_480x222::highlightChat(uint32_t from, uint32_t to, uint8_t ch) +{ + uint32_t index = ((to == UINT32_MAX || from == 0) ? ch : from); + auto it = chats.find(index); + if (it != chats.end()) { + // mark chat in color + lv_obj_set_style_border_color(it->second, colorOrange, LV_PART_MAIN | LV_STATE_DEFAULT); + } +} + +void TFTView_480x222::updateActiveChats(void) +{ + char buf[48]; + sprintf(buf, _p("%d active chat(s)", chats.size()), chats.size()); + lv_label_set_text(objects.top_chats_label, buf); +} + +/** + * @brief Display banner showing to be patient while restoring messages + */ +void TFTView_480x222::notifyRestoreMessages(int32_t percentage) +{ + lv_bar_set_value(objects.message_restore_bar, percentage, LV_ANIM_OFF); +} + +void TFTView_480x222::notifyMessagesRestored(void) +{ + MeshtasticView::notifyMessagesRestored(); + lv_obj_add_flag(objects.msg_restore_panel, LV_OBJ_FLAG_HIDDEN); + updateActiveChats(); + updateNodesFiltered(true); +} + +/** + * @brief display new message popup panel + * + * @param from sender (NULL for removing popup) + * @param to individual or group message + * @param ch received channel + */ +void TFTView_480x222::showMessagePopup(uint32_t from, uint32_t to, uint8_t ch, const char *name) +{ + if (name) { + static char buf[64]; + sprintf(buf, _("New message from \n%s"), name); + buf[38] = '\0'; // cut too long userName + lv_label_set_text(objects.msg_popup_label, buf); + if (to == UINT32_MAX) + objects.msg_popup_button->user_data = (void *)(uint32_t)ch; // store the channel in the button's data + else + objects.msg_popup_button->user_data = (void *)from; // store the node in the button's data + lv_obj_clear_flag(objects.msg_popup_panel, LV_OBJ_FLAG_HIDDEN); + + if (db.module_config.external_notification.alert_message) + lv_disp_trig_activity(NULL); + + lv_group_focus_obj(objects.msg_popup_button); + } +} + +void TFTView_480x222::hideMessagePopup(void) +{ + lv_obj_add_flag(objects.msg_popup_panel, LV_OBJ_FLAG_HIDDEN); +} + +/** + * @brief Display messages of a group channel + * + * @param ch + */ +void TFTView_480x222::showMessages(uint8_t ch) +{ + if (!messagesRestored) { + // display message restoration progress banner + lv_obj_clear_flag(objects.msg_popup_panel, LV_OBJ_FLAG_HIDDEN); + lv_group_focus_obj(objects.msg_popup_button); + return; + } + + lv_obj_add_flag(activeMsgContainer, LV_OBJ_FLAG_HIDDEN); + activeMsgContainer = channelGroup[ch]; + if (!activeMsgContainer) { + activeMsgContainer = newMessageContainer(0, UINT32_MAX, ch); + } + + activeMsgContainer->user_data = (void *)(uint32_t)ch; + lv_obj_clear_flag(activeMsgContainer, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(objects.top_group_chat_label, lv_label_get_text(channel[ch])); + ui_set_active(objects.messages_button, objects.messages_panel, objects.top_group_chat_panel); +} + +/** + * @brief Display messages from a node + * + * @param nodeNum + */ +void TFTView_480x222::showMessages(uint32_t nodeNum) +{ + lv_obj_add_flag(activeMsgContainer, LV_OBJ_FLAG_HIDDEN); + activeMsgContainer = messages[nodeNum]; + if (!activeMsgContainer) { + activeMsgContainer = newMessageContainer(nodeNum, 0, 0); + } + activeMsgContainer->user_data = (void *)nodeNum; + lv_obj_clear_flag(activeMsgContainer, LV_OBJ_FLAG_HIDDEN); + lv_obj_t *p = nodes[nodeNum]; + if (p) { + lv_label_set_text(objects.top_messages_node_label, lv_label_get_text(p->LV_OBJ_IDX(node_lbl_idx))); + ui_set_active(objects.messages_button, objects.messages_panel, objects.top_messages_panel); + switch ((unsigned long)p->LV_OBJ_IDX(node_bat_idx)->user_data) { + case 0: + lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_channel_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case 1: + lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_secure_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + break; + default: + lv_obj_set_style_bg_image_src(objects.top_messages_node_image, &img_lock_slash_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + break; + } + unreadMessages = 0; // TODO: not all messages may be actually read + updateUnreadMessages(); + } else { + // TODO: log error + } +} + +/** + * @brief Place keyboard at a suitable space above or below the text input area + * + * @param textArea + */ +void TFTView_480x222::showKeyboard(lv_obj_t *textArea) +{ + lv_area_t text_coords, kb_coords; + lv_obj_get_coords(textArea, &text_coords); + lv_obj_get_coords(objects.keyboard, &kb_coords); + uint32_t kb_h = kb_coords.y2 - kb_coords.y1; + uint32_t v = lv_display_get_vertical_resolution(displaydriver->getDisplay()); + + if (textArea == objects.message_input_area) { + // if keyboard is to be shown in message input area then scroll the panel using animation + static auto panelAnimCB = [](void *var, int32_t v) { lv_obj_set_y((lv_obj_t *)var, v); }; + static auto kbdAnimCB = [](void *var, int32_t v) { lv_obj_set_y((lv_obj_t *)var, v); }; + + static lv_anim_t a1; + lv_area_t panel_coords; + lv_obj_get_coords(objects.messages_panel, &panel_coords); + + lv_anim_init(&a1); + lv_anim_set_var(&a1, objects.messages_panel); + lv_anim_set_exec_cb(&a1, panelAnimCB); + lv_anim_set_values(&a1, panel_coords.y1, panel_coords.y1 - kb_h); + lv_anim_set_duration(&a1, 300); + lv_anim_set_path_cb(&a1, lv_anim_path_linear); + lv_anim_start(&a1); + + static lv_anim_t a2; + lv_anim_init(&a2); + lv_anim_set_var(&a2, objects.keyboard); + lv_anim_set_exec_cb(&a2, kbdAnimCB); + lv_anim_set_values(&a2, v, v - kb_h); + lv_anim_set_duration(&a2, 300); + lv_anim_set_path_cb(&a2, lv_anim_path_linear); + lv_anim_start(&a2); + } else { + if (text_coords.y1 > kb_h + 30) { + // if enough place above put under top panel + lv_obj_set_pos(objects.keyboard, 0, 28); + } else if ((text_coords.y1 + 10) > v / 2) { + // if text area is at lower half then place above text area + lv_obj_set_pos(objects.keyboard, 0, text_coords.y1 - kb_h - 2); + } else { + // place below text area + lv_obj_set_pos(objects.keyboard, 0, text_coords.y2 + 3); + } + } + lv_keyboard_set_textarea(objects.keyboard, textArea); +} + +void TFTView_480x222::hideKeyboard(lv_obj_t *panel) +{ + lv_area_t kb_coords; + lv_obj_get_coords(objects.keyboard, &kb_coords); + uint32_t kb_h = kb_coords.y2 - kb_coords.y1; + + if (panel == objects.messages_panel) { + static auto panelAnimCB = [](void *var, int32_t v) { lv_obj_set_y((lv_obj_t *)var, v); }; + static auto kbdAnimCB = [](void *var, int32_t v) { lv_obj_set_y((lv_obj_t *)var, v); }; + static auto deleted_cb = [](_lv_anim_t *) { lv_obj_add_flag(objects.keyboard, LV_OBJ_FLAG_HIDDEN); }; + + static lv_anim_t a1; + lv_area_t panel_coords; + lv_obj_get_coords(panel, &panel_coords); + + lv_anim_init(&a1); + lv_anim_set_var(&a1, panel); + lv_anim_set_exec_cb(&a1, panelAnimCB); + lv_anim_set_values(&a1, panel_coords.y1, panel_coords.y1 + kb_h); + lv_anim_set_duration(&a1, 300); + lv_anim_set_path_cb(&a1, lv_anim_path_linear); + lv_anim_start(&a1); + + static lv_anim_t a2; + lv_anim_init(&a2); + lv_anim_set_var(&a2, objects.keyboard); + lv_anim_set_exec_cb(&a2, kbdAnimCB); + lv_anim_set_values(&a2, kb_coords.y1, kb_coords.y1 + kb_h); + lv_anim_set_duration(&a2, 300); + lv_anim_set_path_cb(&a2, lv_anim_path_linear); + lv_anim_set_deleted_cb(&a2, deleted_cb); + lv_anim_start(&a2); + } +} + +lv_obj_t *TFTView_480x222::showQrCode(lv_obj_t *parent, const char *data) +{ + lv_color_t bg_color = colorMesh; + lv_color_t fg_color = lv_palette_darken(LV_PALETTE_BLUE, 4); + qr = lv_qrcode_create(parent); + int32_t size = std::min(lv_obj_get_width(parent), lv_obj_get_height(parent)) - 8; + lv_qrcode_set_size(qr, size); + lv_qrcode_set_dark_color(qr, fg_color); + lv_qrcode_set_light_color(qr, bg_color); + lv_qrcode_update(qr, data, strlen(data)); + lv_obj_center(qr); + lv_obj_set_style_border_color(qr, fg_color, 0); + lv_obj_set_style_border_width(qr, 4, 0); + return qr; +} + +/** + * Enable underlying panel, buttons and scrollbar after it was disabled + */ +void TFTView_480x222::enablePanel(lv_obj_t *panel) +{ + lv_obj_clear_state(panel, LV_STATE_DISABLED); + lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_AUTO); + lv_obj_add_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + + auto enableButtons = [](lv_obj_t *obj, void *) -> lv_obj_tree_walk_res_t { + if (obj->class_p == &lv_button_class) { + lv_obj_clear_state(obj, LV_STATE_DISABLED); + } + return LV_OBJ_TREE_WALK_NEXT; + }; + + lv_obj_tree_walk(panel, enableButtons, NULL); +} + +/** + * Disable underlying panel with it's children buttons and scrollbar + */ +void TFTView_480x222::disablePanel(lv_obj_t *panel) +{ + lv_obj_add_state(panel, LV_STATE_DISABLED); + lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + + auto disableButtons = [](lv_obj_t *obj, void *) -> lv_obj_tree_walk_res_t { + if (obj->class_p == &lv_button_class) { + lv_obj_add_state(obj, LV_STATE_DISABLED); + } + return LV_OBJ_TREE_WALK_NEXT; + }; + + lv_obj_tree_walk(panel, disableButtons, NULL); +} + +/** + * Set focus to first button of a panel + */ +void TFTView_480x222::setGroupFocus(lv_obj_t *panel) +{ + if (panel == objects.home_panel) { + lv_group_focus_obj(objects.home_mail_button); + } else if (panel == objects.nodes_panel) { + lv_group_focus_obj(objects.node_button); + } else if (panel == objects.groups_panel) { + lv_group_focus_obj(objects.channel_button0); + } else if (panel == objects.messages_panel) { + lv_group_focus_obj(objects.message_input_area); + } else if (panel == objects.chats_panel) { + if (chats.size() > 0) { + lv_group_focus_obj(panel->spec_attr->children[1]); // TODO: does not work + } + } else if (panel == objects.map_panel) { + + } else if (panel == objects.settings_screen_lock_panel) { + lv_group_focus_obj(objects.screen_lock_button_matrix); + } else if (panel == objects.controller_panel) { + lv_group_focus_obj(objects.basic_settings_user_button); + } else { + for (int i = 0; i < lv_obj_get_child_count(panel); i++) { + if (panel->spec_attr->children[i]->class_p == &lv_button_class) { + lv_group_focus_obj(panel->spec_attr->children[i]); + break; + } + } + } +} + +/** + * input group used by keyboard and/or pointer for dynamic assignment + */ +void TFTView_480x222::setInputGroup(void) +{ + lv_group_t *group = lv_group_get_default(); + + if (group && inputdriver->hasKeyboardDevice()) + lv_indev_set_group(inputdriver->getKeyboard(), group); + + if (group && inputdriver->hasPointerDevice()) + lv_indev_set_group(inputdriver->getPointer(), group); +} + +void TFTView_480x222::setInputButtonLabel(void) +{ + // update input button label + std::string current_kbd = inputdriver->getCurrentKeyboardDevice(); + std::string current_ptr = inputdriver->getCurrentPointerDevice(); + + char label[40]; + lv_snprintf(label, sizeof(label), _("Input Control: %s/%s"), current_ptr.c_str(), current_kbd.c_str()); + lv_label_set_text(objects.basic_settings_input_label, label); +} +// -------- helpers -------- + +void TFTView_480x222::removeNode(uint32_t nodeNum) +{ + auto it = nodes.find(nodeNum); + if (it != nodes.end()) { + } +} + +void TFTView_480x222::setNodeImage(uint32_t nodeNum, eRole role, bool unmessagable, lv_obj_t *img) +{ + uint32_t bgColor, fgColor; + std::tie(bgColor, fgColor) = nodeColor(nodeNum); + if (unmessagable) { + lv_image_set_src(img, &img_unmessagable_image); + lv_obj_set_style_border_color(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(img, lv_color_hex(0x202020), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_img_recolor(img, lv_color_hex(0xFF5555), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_img_recolor_opa(img, 255, LV_PART_MAIN | LV_STATE_DEFAULT); + return; + } else { + switch (role) { + case client: + case client_mute: + case client_hidden: + case tak: { + lv_image_set_src(img, &img_node_client_image); + break; + } + case router_client: { + lv_image_set_src(img, &img_top_nodes_image); + break; + } + case repeater: + case router: + case router_late: { + lv_image_set_src(img, &img_node_router_image); + break; + } + case tracker: + case sensor: + case lost_and_found: + case tak_tracker: { + lv_image_set_src(img, &img_node_sensor_image); + break; + } + case unknown: { + lv_image_set_src(img, &img_circle_question_image); + break; + } + default: + lv_image_set_src(img, &img_node_client_image); + break; + } + } + lv_obj_set_style_bg_color(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(img, lv_color_hex(bgColor), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_img_recolor_opa(img, fgColor ? 0 : 255, LV_PART_MAIN | LV_STATE_DEFAULT); +} + +void TFTView_480x222::updateNodesStatus(void) +{ + char buf[40]; + lv_snprintf(buf, sizeof(buf), _p("%d of %d nodes online", nodeCount), nodesOnline, nodeCount); + lv_label_set_text(objects.home_nodes_label, buf); + + if (nodesFiltered) + lv_snprintf(buf, sizeof(buf), _("Filter: %d of %d nodes"), nodeCount - nodesFiltered, nodeCount); + lv_label_set_text(objects.top_nodes_online_label, buf); +} + +/** + * @brief Dynamically update all nodes filter and highlight + * Because the update can take quite some time (tens of ms) it is done in smaller + * chunks of 10 nodes per invocation, so it must be periodically called + * TODO: check for side effects if new nodes are inserted or removed during filter processing + * @param reset indicates to start update from beginning of node list otherwise + * continue with iterator position or skip if done + */ +void TFTView_480x222::updateNodesFiltered(bool reset) +{ + static auto it = nodes.begin(); + if (reset || nodesChanged) { + nodesFiltered = 0; + nodesChanged = false; + processingFilter = true; + it = nodes.begin(); + } + + for (int i = 0; i < 10 && it != nodes.end(); i++) { + applyNodesFilter(it->first, true); + it++; + } + + if (it == nodes.end()) { + processingFilter = false; + } + updateNodesStatus(); +} + +/** + * @brief Update last heard display/user_data/counter to current time + * + * @param nodeNum + */ +void TFTView_480x222::updateLastHeard(uint32_t nodeNum) +{ + auto it = nodes.find(nodeNum); + if (it != nodes.end() && it->second) { + time_t lastHeard = (time_t)it->second->LV_OBJ_IDX(node_lh_idx)->user_data; + it->second->LV_OBJ_IDX(node_lh_idx)->user_data = (void *)curtime; + lv_label_set_text(it->second->LV_OBJ_IDX(node_lh_idx), _("now")); + if (it->first != ownNode) { + if (lastHeard > 0 && curtime - lastHeard >= secs_until_offline) { + nodesOnline++; + applyNodesFilter(nodeNum); + updateNodesStatus(); + } + // move to top position + lv_obj_move_to_index(it->second, 1); + + // re-arrange the group linked list i.e. move the node after the top position + lv_ll_t *lv_group_ll = &lv_group_get_default()->obj_ll; + void *act = it->second->LV_OBJ_IDX(node_btn_idx)->user_data; + if (lv_group_ll && act) + _lv_ll_move_before(lv_group_ll, act, _lv_ll_get_next(lv_group_ll, topNodeLL)); + } + } +} + +/** + * @brief update last heard display for all nodes; also update nodes online + * + */ +void TFTView_480x222::updateAllLastHeard(void) +{ + uint16_t online = 0; + time_t lastHeard; + for (auto it : nodes) { + char buf[32]; + if (it.first == ownNode) { // own node is always now, so do update + lastHeard = curtime; + it.second->LV_OBJ_IDX(node_lh_idx)->user_data = (void *)lastHeard; + } else { + lastHeard = (time_t)it.second->LV_OBJ_IDX(node_lh_idx)->user_data; + } + if (lastHeard) { + bool isOnline = lastHeardToString(lastHeard, buf); + lv_label_set_text(it.second->LV_OBJ_IDX(node_lh_idx), buf); + if (isOnline) + online++; + } + } + nodesOnline = online; + updateNodesFiltered(true); + updateNodesStatus(); +} + +void TFTView_480x222::updateUnreadMessages(void) +{ + char buf[64]; + if (unreadMessages > 0) { + sprintf(buf, unreadMessages == 1 ? _("%d new message") : _("%d new messages"), unreadMessages); + lv_obj_set_style_bg_img_src(objects.home_mail_button, &img_home_mail_unread_button_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + } else { + strcpy(buf, _("no new messages")); + lv_obj_set_style_bg_img_src(objects.home_mail_button, &img_home_mail_button_image, LV_PART_MAIN | LV_STATE_DEFAULT); + } + lv_label_set_text(objects.home_mail_label, buf); +} + +/** + * @brief Called once a second to update time label + * + */ +void TFTView_480x222::updateTime(void) +{ + char buf[80]; + time_t curr_time; +#ifdef ARCH_PORTDUINO + time(&curr_time); +#else + curr_time = actTime; +#endif + tm *curr_tm = localtime(&curr_time); + + int len = 0; + if (VALID_TIME(curr_time) && (unsigned long)objects.home_time_button->user_data == 0) { + if (db.config.display.use_12h_clock) { + len = strftime(buf, 40, "%I:%M:%S %p\n%a %d-%b-%g", curr_tm); + } else { + len = strftime(buf, 40, "%T %Z%z\n%a %d-%b-%g", curr_tm); + } + } else { + uint32_t uptime = millis() / 1000; + int hours = uptime / 3600; + uptime -= hours * 3600; + int minutes = uptime / 60; + int seconds = uptime - minutes * 60; + + sprintf(&buf[len], _("uptime: %02d:%02d:%02d"), hours, minutes, seconds); + } + lv_label_set_text(objects.home_time_label, buf); +} + +bool TFTView_480x222::updateSDCard(void) +{ + formatSD = false; + if (sdCard) { + delete sdCard; + sdCard = nullptr; + } +#ifdef HAS_SDCARD + char buf[64]; +#ifdef HAS_SD_MMC + sdCard = new SDCard; +#else + sdCard = new SdFsCard; +#endif + ISdCard::ErrorType err = ISdCard::ErrorType::eNoError; + if (sdCard->init() && sdCard->cardType() != ISdCard::eNone) { + ILOG_DEBUG("SdCard init successful, card type: %d", sdCard->cardType()); + ISdCard::CardType cardType = sdCard->cardType(); + ISdCard::FatType fatType = sdCard->fatType(); + uint32_t usedSpace = sdCard->usedBytes() / (1024 * 1024); + uint32_t totalSpace = sdCard->cardSize() / (1024 * 1024); + uint32_t totalSpaceGB = (sdCard->cardSize() + 500000000ULL) / (1000ULL * 1000ULL * 1000ULL); + + sprintf(buf, _("%s: %d GB (%s)\nUsed: %0.2f GB (%d%%)"), + cardType == ISdCard::eMMC ? "MMC" + : cardType == ISdCard::eSD ? "SDSC" + : cardType == ISdCard::eSDHC ? "SDHC" + : cardType == ISdCard::eSDXC ? "SDXC" + : "UNKN", + totalSpaceGB, + fatType == ISdCard::eExFat ? "exFAT" + : fatType == ISdCard::eFat32 ? "FAT32" + : fatType == ISdCard::eFat16 ? "FAT16" + : "???", + float(sdCard->usedBytes()) / 1024.0f / 1024.0f / 1024.0f, + totalSpace ? ((usedSpace * 100) + totalSpace / 2) / totalSpace : 0); + Themes::recolorButton(objects.home_sd_card_button, true); + Themes::recolorText(objects.home_sd_card_label, true); + cardDetected = true; + } else { + ILOG_DEBUG("SdFsCard init failed"); + err = sdCard->errorType(); + delete sdCard; + sdCard = nullptr; + } + + if (!cardDetected || err != ISdCard::ErrorType::eNoError) { + switch (err) { + case ISdCard::ErrorType::eSlotEmpty: + ILOG_ERROR("SD card slot empty"); + lv_snprintf(buf, sizeof(buf), _("SD slot empty")); + break; + case ISdCard::ErrorType::eFormatError: + ILOG_ERROR("SD invalid format"); + lv_snprintf(buf, sizeof(buf), _("SD invalid format")); + formatSD = true; + break; + case ISdCard::ErrorType::eNoMbrError: + ILOG_ERROR("SD mbr not found"); + lv_snprintf(buf, sizeof(buf), _("SD mbr not found")); + formatSD = true; + break; + case ISdCard::ErrorType::eCardError: + ILOG_ERROR("SD card error"); + lv_snprintf(buf, sizeof(buf), _("SD card error")); + break; + default: + ILOG_ERROR("SD unknown error"); + lv_snprintf(buf, sizeof(buf), _("SD unknown error")); + break; + } + Themes::recolorButton(objects.home_sd_card_button, false); + Themes::recolorText(objects.home_sd_card_label, false); + // allow backup/restore only if there is an SD card detected + lv_obj_add_state(objects.basic_settings_backup_restore_button, LV_STATE_DISABLED); + } else { + // enable backup/restore + lv_obj_clear_state(objects.basic_settings_backup_restore_button, LV_STATE_DISABLED); + } + lv_label_set_text(objects.home_sd_card_label, buf); +#else + lv_obj_add_flag(objects.home_sd_card_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(objects.home_sd_card_label, LV_OBJ_FLAG_HIDDEN); +#if defined(ARCH_PORTDUINO) + cardDetected = true; // use PortduinoFS instead + sdCard = new SDCard; +#endif +#endif + if (!sdCard) + sdCard = new NoSdCard; + return cardDetected; +} + +void TFTView_480x222::formatSDCard(void) +{ + if (sdCard) { + delete sdCard; + sdCard = nullptr; + } +#ifdef HAS_SDCARD +#ifdef HAS_SD_MMC + sdCard = new SDCard; +#else + sdCard = new SdFsCard; +#endif + ILOG_DEBUG("formatting SD card"); + if (sdCard->format()) { + updateSDCard(); + } else { + lv_label_set_text(objects.home_sd_card_label, "SD format failed"); + } +#endif + if (!sdCard) + sdCard = new NoSdCard; +} + +void TFTView_480x222::updateFreeMem(void) +{ + // only update if HomePanel is active (since this is some critical code that did crash sporadically) + if (activePanel == objects.home_panel && (unsigned long)objects.home_memory_button->user_data) { + char buf[64]; + uint32_t freeHeap = 0; + uint32_t freeHeap_pct = 0; + + lv_mem_monitor_t mon; + lv_mem_monitor(&mon); + +#ifdef ARDUINO_ARCH_ESP32 + freeHeap = ESP.getFreeHeap(); + freeHeap_pct = 100 * freeHeap / ESP.getHeapSize(); + sprintf(buf, _("Heap: %d (%d%%)\nLVGL: %d (%d%%)"), freeHeap, freeHeap_pct, mon.free_size, 100 - mon.used_pct); +#elif defined(ARCH_PORTDUINO) + static uint32_t totalMem = LinuxHelper::getTotalMem(); + if (totalMem != 0) { + freeHeap = LinuxHelper::getAvailableMem(); + freeHeap_pct = 100 * freeHeap / totalMem; + } + sprintf(buf, _("Heap: %d (%d%%)\nLVGL: %d (%d%%)"), freeHeap, freeHeap_pct, mon.free_size / 1024, 100 - mon.used_pct); +#else + buf[0] = '\0'; +#endif + lv_label_set_text(objects.home_memory_label, buf); + } +} + +void TFTView_480x222::task_handler(void) +{ + MeshtasticView::task_handler(); + + if (screensInitialised) { + if (map) + map->task_handler(); + + if (curtime - lastrun1 >= 1) { // call every 1s + if (map) { + updateLocationMap(THIS->map->getObjectsOnMap()); + } + + lastrun1 = curtime; + actTime++; + updateTime(); + + if (curtime - lastrun5 >= 5) { // call every 5s + lastrun5 = curtime; + if (scans > 0 && activePanel == objects.signal_scanner_panel) { + scanSignal(scans); + scans--; + } + if (startTime) { + if (curtime - startTime > 30) { + lv_label_set_text(objects.trace_route_start_label, _("Start")); + lv_obj_set_style_outline_color(objects.trace_route_start_button, colorMesh, + LV_PART_MAIN | LV_STATE_DEFAULT); + removeSpinner(); + } else { + char buf[16]; + sprintf(buf, "%ds", ((35 - (curtime - startTime)) / 5) * 5); + lv_label_set_text(objects.trace_route_start_label, buf); + } + } + } + if (curtime - lastrun10 >= 10) { // call every 10s + lastrun10 = curtime; + updateFreeMem(); + + if ((db.config.network.wifi_enabled || db.module_config.mqtt.enabled) && !displaydriver->isPowersaving()) { + controller->requestDeviceConnectionStatus(); + } + } + if (curtime - lastrun60 >= 60) { // call every 60s + lastrun60 = curtime; + updateAllLastHeard(); + + if (detectorRunning) { + controller->sendPing(); + } + + // if we didn't hear any node for 1h assume we have no signal + if (curtime - lastHeard > secs_until_offline) { + lv_obj_set_style_bg_image_src(objects.home_signal_button, &img_home_no_signal_image, + LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_text(objects.home_signal_label, _("no signal")); + lv_label_set_text(objects.home_signal_pct_label, ""); + } + } + } + if (processingFilter || nodesChanged) { + updateNodesFiltered(nodesChanged); + } + } +} + +// === lvgl C style callbacks === + +extern "C" { + +void action_on_boot_screen_displayed(lv_event_t *e) +{ + ILOG_DEBUG("action_on_boot_screen_displayed()"); +} } -#endif \ No newline at end of file +#endif diff --git a/source/graphics/TFT/Themes.cpp b/source/graphics/TFT/Themes.cpp index 71521443..7d3e2590 100644 --- a/source/graphics/TFT/Themes.cpp +++ b/source/graphics/TFT/Themes.cpp @@ -1,4 +1,4 @@ -#ifdef VIEW_320x240 +#if defined(VIEW_320x240) || defined(VIEW_480x222) #include "graphics/view/TFT/Themes.h" #include "stdint.h" From 771809c527dde196f04bac74758525ea246877ac Mon Sep 17 00:00:00 2001 From: katethefox Date: Sun, 11 Jan 2026 14:17:03 -0600 Subject: [PATCH 04/10] Add Sym/Alt modifier visual indicator - Display orange "S" at bottom-left when Sym key is pressed - Indicator disappears when any other key is pressed (one-shot) - Added AltIndicatorCallback mechanism to I2CKeyboardInputDriver - Provides visual feedback for modifier state Co-Authored-By: Claude Opus 4.5 --- include/input/I2CKeyboardInputDriver.h | 9 ++++++++- source/graphics/TFT/TFTView_320x240.cpp | 19 +++++++++++++++++++ source/graphics/TFT/TFTView_480x222.cpp | 19 +++++++++++++++++++ source/input/I2CKeyboardInputDriver.cpp | 9 +++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/include/input/I2CKeyboardInputDriver.h b/include/input/I2CKeyboardInputDriver.h index d35dd8cf..5fad469c 100644 --- a/include/input/I2CKeyboardInputDriver.h +++ b/include/input/I2CKeyboardInputDriver.h @@ -11,6 +11,9 @@ typedef void (*NavigationCallback)(void); // Callback type for scroll events (direction: +1 = down, -1 = up) typedef void (*ScrollCallback)(int direction); +// Callback type for alt/sym indicator updates +typedef void (*AltIndicatorCallback)(bool active); + class I2CKeyboardInputDriver : public InputDriver { public: @@ -35,16 +38,20 @@ class I2CKeyboardInputDriver : public InputDriver // ALT modifier state for scroll-while-typing feature static bool isAltModifierHeld(void) { return altModifierHeld; } - static void setAltModifierHeld(bool held) { altModifierHeld = held; } + static void setAltModifierHeld(bool held); // Scroll callback for alt+encoder scrolling static void setScrollCallback(ScrollCallback cb) { scrollCallback = cb; } static ScrollCallback getScrollCallback(void) { return scrollCallback; } + // Alt/Sym indicator callback for UI updates + static void setAltIndicatorCallback(AltIndicatorCallback cb) { altIndicatorCallback = cb; } + protected: static NavigationCallback navigateHomeCallback; static bool altModifierHeld; static ScrollCallback scrollCallback; + static AltIndicatorCallback altIndicatorCallback; bool registerI2CKeyboard(I2CKeyboardInputDriver *driver, std::string name, uint8_t address); private: diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 8f8343f7..99cd35e1 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -119,6 +119,7 @@ time_t TFTView_320x240::startTime = 0; uint32_t TFTView_320x240::pinKeys = 0; bool TFTView_320x240::screenLocked = false; bool TFTView_320x240::screenUnlockRequest = false; +static lv_obj_t *symIndicator = nullptr; TFTView_320x240 *TFTView_320x240::instance(void) { @@ -440,6 +441,14 @@ void TFTView_320x240::init_screens(void) updateFreeMem(); + // Create sym/alt modifier indicator (hidden by default) + symIndicator = lv_label_create(objects.main_screen); + lv_label_set_text(symIndicator, "S"); + lv_obj_set_style_text_color(symIndicator, lv_color_hex(0xFF8C00), LV_PART_MAIN); // Orange + lv_obj_set_style_text_font(symIndicator, &lv_font_montserrat_16, LV_PART_MAIN); + lv_obj_align(symIndicator, LV_ALIGN_BOTTOM_LEFT, 5, -5); + lv_obj_add_flag(symIndicator, LV_OBJ_FLAG_HIDDEN); + screensInitialised = true; state = MeshtasticView::eInitDone; ILOG_DEBUG("TFTView_320x240 init done."); @@ -747,6 +756,16 @@ void TFTView_320x240::ui_events_init(void) } }); + // Register callback for sym/alt indicator + I2CKeyboardInputDriver::setAltIndicatorCallback([](bool active) { + if (symIndicator) { + if (active) + lv_obj_clear_flag(symIndicator, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(symIndicator, LV_OBJ_FLAG_HIDDEN); + } + }); + // home buttons lv_obj_add_event_cb(objects.home_mail_button, this->ui_event_EnvelopeButton, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(objects.home_nodes_button, this->ui_event_OnlineNodesButton, LV_EVENT_ALL, NULL); diff --git a/source/graphics/TFT/TFTView_480x222.cpp b/source/graphics/TFT/TFTView_480x222.cpp index 0ab7ea70..6114a3db 100644 --- a/source/graphics/TFT/TFTView_480x222.cpp +++ b/source/graphics/TFT/TFTView_480x222.cpp @@ -119,6 +119,7 @@ time_t TFTView_480x222::startTime = 0; uint32_t TFTView_480x222::pinKeys = 0; bool TFTView_480x222::screenLocked = false; bool TFTView_480x222::screenUnlockRequest = false; +static lv_obj_t *symIndicator = nullptr; TFTView_480x222 *TFTView_480x222::instance(void) { @@ -440,6 +441,14 @@ void TFTView_480x222::init_screens(void) updateFreeMem(); + // Create sym/alt modifier indicator (hidden by default) + symIndicator = lv_label_create(objects.main_screen); + lv_label_set_text(symIndicator, "S"); + lv_obj_set_style_text_color(symIndicator, lv_color_hex(0xFF8C00), LV_PART_MAIN); // Orange + lv_obj_set_style_text_font(symIndicator, &lv_font_montserrat_16, LV_PART_MAIN); + lv_obj_align(symIndicator, LV_ALIGN_BOTTOM_LEFT, 5, -5); + lv_obj_add_flag(symIndicator, LV_OBJ_FLAG_HIDDEN); + screensInitialised = true; state = MeshtasticView::eInitDone; ILOG_DEBUG("TFTView_480x222 init done."); @@ -747,6 +756,16 @@ void TFTView_480x222::ui_events_init(void) } }); + // Register callback for sym/alt indicator + I2CKeyboardInputDriver::setAltIndicatorCallback([](bool active) { + if (symIndicator) { + if (active) + lv_obj_clear_flag(symIndicator, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(symIndicator, LV_OBJ_FLAG_HIDDEN); + } + }); + // home buttons lv_obj_add_event_cb(objects.home_mail_button, this->ui_event_EnvelopeButton, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(objects.home_nodes_button, this->ui_event_OnlineNodesButton, LV_EVENT_ALL, NULL); diff --git a/source/input/I2CKeyboardInputDriver.cpp b/source/input/I2CKeyboardInputDriver.cpp index acaaaff8..7d56b100 100644 --- a/source/input/I2CKeyboardInputDriver.cpp +++ b/source/input/I2CKeyboardInputDriver.cpp @@ -11,6 +11,15 @@ I2CKeyboardInputDriver::KeyboardList I2CKeyboardInputDriver::i2cKeyboardList; NavigationCallback I2CKeyboardInputDriver::navigateHomeCallback = nullptr; bool I2CKeyboardInputDriver::altModifierHeld = false; ScrollCallback I2CKeyboardInputDriver::scrollCallback = nullptr; +AltIndicatorCallback I2CKeyboardInputDriver::altIndicatorCallback = nullptr; + +void I2CKeyboardInputDriver::setAltModifierHeld(bool held) +{ + altModifierHeld = held; + if (altIndicatorCallback) { + altIndicatorCallback(held); + } +} I2CKeyboardInputDriver::I2CKeyboardInputDriver(void) {} From db45ea49ba961dc8dc532a44cd224c61c5be3043 Mon Sep 17 00:00:00 2001 From: katethefox Date: Sun, 11 Jan 2026 15:19:24 -0600 Subject: [PATCH 05/10] Remove verbose keyboard polling debug log Co-Authored-By: Claude Opus 4.5 --- source/input/I2CKeyboardInputDriver.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/source/input/I2CKeyboardInputDriver.cpp b/source/input/I2CKeyboardInputDriver.cpp index 7d56b100..89094506 100644 --- a/source/input/I2CKeyboardInputDriver.cpp +++ b/source/input/I2CKeyboardInputDriver.cpp @@ -318,8 +318,6 @@ void TLoraPagerKeyboardInputDriver::init(void) ILOG_INFO("TLoraPagerKeyboardInputDriver initialized (4x10 matrix)"); } -static uint32_t lastKeyboardLog = 0; - void TLoraPagerKeyboardInputDriver::readKeyboard(uint8_t address, lv_indev_t *indev, lv_indev_data_t *data) { data->state = LV_INDEV_STATE_RELEASED; @@ -333,12 +331,6 @@ void TLoraPagerKeyboardInputDriver::readKeyboard(uint8_t address, lv_indev_t *in if (Wire.available()) { uint8_t keyCount = Wire.read() & 0x0F; - // Log periodically to show we're polling - uint32_t now = millis(); - if (now - lastKeyboardLog > 5000) { - ILOG_DEBUG("T-Pager keyboard poll: keyCount=%d mod=%d", keyCount, modifierState); - lastKeyboardLog = now; - } if (keyCount > 0) { // Read key event from FIFO Wire.beginTransmission(address); From 9eee420aa73798dd7d2ffc9d854fef1c08c46f90 Mon Sep 17 00:00:00 2001 From: katethefox Date: Sun, 11 Jan 2026 16:41:51 -0600 Subject: [PATCH 06/10] Fix chat list encoder direction and add unread message badge - Reverse flex flow on chats_panel so encoder clockwise moves UP (to newer chats) - Add red message count badge on Messages button showing unread count - Use bolder font (montserrat_20) for Sym indicator and message badge Co-Authored-By: Claude Opus 4.5 --- source/graphics/TFT/TFTView_320x240.cpp | 27 ++++++++++++++++++++++++- source/graphics/TFT/TFTView_480x222.cpp | 27 ++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 99cd35e1..dbf2f3e0 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -120,6 +120,7 @@ uint32_t TFTView_320x240::pinKeys = 0; bool TFTView_320x240::screenLocked = false; bool TFTView_320x240::screenUnlockRequest = false; static lv_obj_t *symIndicator = nullptr; +static lv_obj_t *messagesBadge = nullptr; TFTView_320x240 *TFTView_320x240::instance(void) { @@ -445,10 +446,22 @@ void TFTView_320x240::init_screens(void) symIndicator = lv_label_create(objects.main_screen); lv_label_set_text(symIndicator, "S"); lv_obj_set_style_text_color(symIndicator, lv_color_hex(0xFF8C00), LV_PART_MAIN); // Orange - lv_obj_set_style_text_font(symIndicator, &lv_font_montserrat_16, LV_PART_MAIN); + lv_obj_set_style_text_font(symIndicator, &ui_font_montserrat_20, LV_PART_MAIN); lv_obj_align(symIndicator, LV_ALIGN_BOTTOM_LEFT, 5, -5); lv_obj_add_flag(symIndicator, LV_OBJ_FLAG_HIDDEN); + // Reverse flex flow for chats panel so encoder direction feels natural + // (clockwise = up in list, counter-clockwise = down) + lv_obj_set_style_flex_flow(objects.chats_panel, LV_FLEX_FLOW_COLUMN_REVERSE, LV_PART_MAIN | LV_STATE_DEFAULT); + + // Create message counter badge on messages button (hidden by default) + messagesBadge = lv_label_create(objects.messages_button); + lv_label_set_text(messagesBadge, ""); + lv_obj_set_style_text_color(messagesBadge, lv_color_hex(0xFF0000), LV_PART_MAIN); // Red + lv_obj_set_style_text_font(messagesBadge, &ui_font_montserrat_20, LV_PART_MAIN); + lv_obj_align(messagesBadge, LV_ALIGN_TOP_RIGHT, -2, 2); + lv_obj_add_flag(messagesBadge, LV_OBJ_FLAG_HIDDEN); + screensInitialised = true; state = MeshtasticView::eInitDone; ILOG_DEBUG("TFTView_320x240 init done."); @@ -7234,6 +7247,18 @@ void TFTView_320x240::updateUnreadMessages(void) lv_obj_set_style_bg_img_src(objects.home_mail_button, &img_home_mail_button_image, LV_PART_MAIN | LV_STATE_DEFAULT); } lv_label_set_text(objects.home_mail_label, buf); + + // Update messages button badge + if (messagesBadge) { + if (unreadMessages > 0) { + char badgeBuf[8]; + sprintf(badgeBuf, "%d", unreadMessages > 99 ? 99 : unreadMessages); + lv_label_set_text(messagesBadge, badgeBuf); + lv_obj_clear_flag(messagesBadge, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(messagesBadge, LV_OBJ_FLAG_HIDDEN); + } + } } /** diff --git a/source/graphics/TFT/TFTView_480x222.cpp b/source/graphics/TFT/TFTView_480x222.cpp index 6114a3db..d6bd4cd7 100644 --- a/source/graphics/TFT/TFTView_480x222.cpp +++ b/source/graphics/TFT/TFTView_480x222.cpp @@ -120,6 +120,7 @@ uint32_t TFTView_480x222::pinKeys = 0; bool TFTView_480x222::screenLocked = false; bool TFTView_480x222::screenUnlockRequest = false; static lv_obj_t *symIndicator = nullptr; +static lv_obj_t *messagesBadge = nullptr; TFTView_480x222 *TFTView_480x222::instance(void) { @@ -445,10 +446,22 @@ void TFTView_480x222::init_screens(void) symIndicator = lv_label_create(objects.main_screen); lv_label_set_text(symIndicator, "S"); lv_obj_set_style_text_color(symIndicator, lv_color_hex(0xFF8C00), LV_PART_MAIN); // Orange - lv_obj_set_style_text_font(symIndicator, &lv_font_montserrat_16, LV_PART_MAIN); + lv_obj_set_style_text_font(symIndicator, &ui_font_montserrat_20, LV_PART_MAIN); lv_obj_align(symIndicator, LV_ALIGN_BOTTOM_LEFT, 5, -5); lv_obj_add_flag(symIndicator, LV_OBJ_FLAG_HIDDEN); + // Reverse flex flow for chats panel so encoder direction feels natural + // (clockwise = up in list, counter-clockwise = down) + lv_obj_set_style_flex_flow(objects.chats_panel, LV_FLEX_FLOW_COLUMN_REVERSE, LV_PART_MAIN | LV_STATE_DEFAULT); + + // Create message counter badge on messages button (hidden by default) + messagesBadge = lv_label_create(objects.messages_button); + lv_label_set_text(messagesBadge, ""); + lv_obj_set_style_text_color(messagesBadge, lv_color_hex(0xFF0000), LV_PART_MAIN); // Red + lv_obj_set_style_text_font(messagesBadge, &ui_font_montserrat_20, LV_PART_MAIN); + lv_obj_align(messagesBadge, LV_ALIGN_TOP_RIGHT, -2, 2); + lv_obj_add_flag(messagesBadge, LV_OBJ_FLAG_HIDDEN); + screensInitialised = true; state = MeshtasticView::eInitDone; ILOG_DEBUG("TFTView_480x222 init done."); @@ -7234,6 +7247,18 @@ void TFTView_480x222::updateUnreadMessages(void) lv_obj_set_style_bg_img_src(objects.home_mail_button, &img_home_mail_button_image, LV_PART_MAIN | LV_STATE_DEFAULT); } lv_label_set_text(objects.home_mail_label, buf); + + // Update messages button badge + if (messagesBadge) { + if (unreadMessages > 0) { + char badgeBuf[8]; + sprintf(badgeBuf, "%d", unreadMessages > 99 ? 99 : unreadMessages); + lv_label_set_text(messagesBadge, badgeBuf); + lv_obj_clear_flag(messagesBadge, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(messagesBadge, LV_OBJ_FLAG_HIDDEN); + } + } } /** From 50c6214441f872df658d9cd7c83cc0ae22338efa Mon Sep 17 00:00:00 2001 From: katethefox Date: Sun, 11 Jan 2026 18:37:43 -0600 Subject: [PATCH 07/10] Add display wake request mechanism for keyboard and message wake - Add static requestWake()/isWakeRequested()/clearWakeRequest() to DisplayDriver for thread-safe wake requests from external contexts - Check wake request flag in LGFXDriver power save loop alongside GPIO/activity - Add InputEventCallback to I2CKeyboardInputDriver to notify firmware on keypress - Call lv_display_trigger_activity() on keyboard input to reset LVGL inactivity This enables TFT devices to properly wake from power save when receiving messages or keyboard input via the firmware's PowerFSM callbacks. Co-Authored-By: Claude Opus 4.5 --- include/graphics/driver/DisplayDriver.h | 6 ++++++ include/graphics/driver/LGFXDriver.h | 12 ++++++++---- include/input/I2CKeyboardInputDriver.h | 8 ++++++++ source/graphics/driver/DisplayDriver.cpp | 3 +++ source/input/I2CKeyboardInputDriver.cpp | 12 ++++++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/include/graphics/driver/DisplayDriver.h b/include/graphics/driver/DisplayDriver.h index 2a95d8cf..e7911639 100644 --- a/include/graphics/driver/DisplayDriver.h +++ b/include/graphics/driver/DisplayDriver.h @@ -24,6 +24,11 @@ class DisplayDriver virtual void printConfig(void) {} virtual ~DisplayDriver() {} + // Request display wake from any context (thread-safe) + static void requestWake(void) { wakeRequested = true; } + static bool isWakeRequested(void) { return wakeRequested; } + static void clearWakeRequest(void) { wakeRequested = false; } + virtual uint8_t getBrightness() { return 255; } virtual void setBrightness(uint8_t timeout) {} @@ -42,4 +47,5 @@ class DisplayDriver DeviceGUI *view; uint16_t screenWidth; uint16_t screenHeight; + static volatile bool wakeRequested; }; diff --git a/include/graphics/driver/LGFXDriver.h b/include/graphics/driver/LGFXDriver.h index 3b2a09ee..8f678edc 100644 --- a/include/graphics/driver/LGFXDriver.h +++ b/include/graphics/driver/LGFXDriver.h @@ -121,12 +121,15 @@ template void LGFXDriver::task_handler(void) pin_int = BUTTON_PIN; #endif } + // Check wake conditions: GPIO interrupt, activity timeout, or explicit wake request if ((pin_int >= 0 && DisplayDriver::view->sleep(pin_int)) || - (screenTimeout + 50 > lv_display_get_inactive_time(NULL) && !DisplayDriver::view->isScreenLocked())) { + (screenTimeout + 50 > lv_display_get_inactive_time(NULL) && !DisplayDriver::view->isScreenLocked()) || + DisplayDriver::isWakeRequested()) { delay(2); // let the CPU finish to restore all register in case of light sleep - // woke up by touch or button + // woke up by touch, button, or external wake request ILOG_INFO("leaving powersave"); powerSaving = false; + DisplayDriver::clearWakeRequest(); DisplayDriver::view->triggerHeartbeat(); lgfx->powerSaveOff(); lgfx->wakeup(); @@ -152,12 +155,13 @@ template void LGFXDriver::task_handler(void) lgfx->powerSaveOn(); powerSaving = true; } - if (screenTimeout > lv_display_get_inactive_time(NULL)) { + if (screenTimeout > lv_display_get_inactive_time(NULL) || DisplayDriver::isWakeRequested()) { DisplayDriver::view->blankScreen(false); lgfx->powerSaveOff(); lgfx->wakeup(); powerSaving = false; - lv_disp_trig_activity(NULL); + DisplayDriver::clearWakeRequest(); + lv_display_trigger_activity(NULL); } } } diff --git a/include/input/I2CKeyboardInputDriver.h b/include/input/I2CKeyboardInputDriver.h index 5fad469c..1eb3661e 100644 --- a/include/input/I2CKeyboardInputDriver.h +++ b/include/input/I2CKeyboardInputDriver.h @@ -14,6 +14,9 @@ typedef void (*ScrollCallback)(int direction); // Callback type for alt/sym indicator updates typedef void (*AltIndicatorCallback)(bool active); +// Callback type for input events (to wake screen on keypress) +typedef void (*InputEventCallback)(void); + class I2CKeyboardInputDriver : public InputDriver { public: @@ -47,11 +50,16 @@ class I2CKeyboardInputDriver : public InputDriver // Alt/Sym indicator callback for UI updates static void setAltIndicatorCallback(AltIndicatorCallback cb) { altIndicatorCallback = cb; } + // Input event callback to wake screen on keypress + static void setInputEventCallback(InputEventCallback cb) { inputEventCallback = cb; } + static InputEventCallback getInputEventCallback(void) { return inputEventCallback; } + protected: static NavigationCallback navigateHomeCallback; static bool altModifierHeld; static ScrollCallback scrollCallback; static AltIndicatorCallback altIndicatorCallback; + static InputEventCallback inputEventCallback; bool registerI2CKeyboard(I2CKeyboardInputDriver *driver, std::string name, uint8_t address); private: diff --git a/source/graphics/driver/DisplayDriver.cpp b/source/graphics/driver/DisplayDriver.cpp index 204763e8..b72af726 100644 --- a/source/graphics/driver/DisplayDriver.cpp +++ b/source/graphics/driver/DisplayDriver.cpp @@ -1,6 +1,9 @@ #include "graphics/driver/DisplayDriver.h" #include "util/ILog.h" +// Static member initialization +volatile bool DisplayDriver::wakeRequested = false; + #if LV_USE_PROFILER #if defined(ARCH_PORTDUINO) #include diff --git a/source/input/I2CKeyboardInputDriver.cpp b/source/input/I2CKeyboardInputDriver.cpp index 89094506..5d324ce2 100644 --- a/source/input/I2CKeyboardInputDriver.cpp +++ b/source/input/I2CKeyboardInputDriver.cpp @@ -6,12 +6,17 @@ #include "indev/lv_indev_private.h" #include "widgets/textarea/lv_textarea.h" +#include "misc/lv_types.h" + +// Forward declare the display activity function +extern "C" void lv_display_trigger_activity(lv_display_t * disp); I2CKeyboardInputDriver::KeyboardList I2CKeyboardInputDriver::i2cKeyboardList; NavigationCallback I2CKeyboardInputDriver::navigateHomeCallback = nullptr; bool I2CKeyboardInputDriver::altModifierHeld = false; ScrollCallback I2CKeyboardInputDriver::scrollCallback = nullptr; AltIndicatorCallback I2CKeyboardInputDriver::altIndicatorCallback = nullptr; +InputEventCallback I2CKeyboardInputDriver::inputEventCallback = nullptr; void I2CKeyboardInputDriver::setAltModifierHeld(bool held) { @@ -50,6 +55,13 @@ void I2CKeyboardInputDriver::keyboard_read(lv_indev_t *indev, lv_indev_data_t *d for (auto &keyboardDef : i2cKeyboardList) { keyboardDef->driver->readKeyboard(keyboardDef->address, indev, data); if (data->state == LV_INDEV_STATE_PRESSED) { + // Reset LVGL inactivity timer to wake screen from power save + lv_display_trigger_activity(NULL); + ILOG_INFO("Keyboard pressed - triggered activity to wake screen"); + // Notify firmware to wake screen on keypress + if (inputEventCallback) { + inputEventCallback(); + } // If any keyboard reports a key press, we stop reading further return; } From f0a6b1cf7b49af60b58758a9b97c679ef042bfe6 Mon Sep 17 00:00:00 2001 From: katethefox Date: Mon, 12 Jan 2026 11:41:01 -0600 Subject: [PATCH 08/10] Implement hold-to-scroll behavior for Sym key in T-Lora Pager keyboard input --- generated/ui_480x222 | 1 + source/input/I2CKeyboardInputDriver.cpp | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) create mode 120000 generated/ui_480x222 diff --git a/generated/ui_480x222 b/generated/ui_480x222 new file mode 120000 index 00000000..df09f1f0 --- /dev/null +++ b/generated/ui_480x222 @@ -0,0 +1 @@ +ui_320x240 \ No newline at end of file diff --git a/source/input/I2CKeyboardInputDriver.cpp b/source/input/I2CKeyboardInputDriver.cpp index 5d324ce2..37339966 100644 --- a/source/input/I2CKeyboardInputDriver.cpp +++ b/source/input/I2CKeyboardInputDriver.cpp @@ -354,6 +354,17 @@ void TLoraPagerKeyboardInputDriver::readKeyboard(uint8_t address, lv_indev_t *in uint8_t keyCode = keyEvent & 0x7F; bool pressed = (keyEvent & 0x80) != 0; + // Handle Sym key release for hold-to-scroll behavior + if (!pressed && keyCode > 0 && keyCode <= 31) { + uint8_t keyIndex = keyCode - 1; + if (keyIndex == MODIFIER_SYM_KEY) { + // Sym released - disable scroll mode (but keep modifierState for symbol entry) + I2CKeyboardInputDriver::setAltModifierHeld(false); + ILOG_DEBUG("T-Pager: Sym released, scroll disabled"); + } + return; // Don't process other key releases + } + if (pressed && keyCode > 0 && keyCode <= 31) { uint8_t keyIndex = keyCode - 1; @@ -365,11 +376,11 @@ void TLoraPagerKeyboardInputDriver::readKeyboard(uint8_t address, lv_indev_t *in return; // Don't output a key for modifier press } if (keyIndex == MODIFIER_SYM_KEY) { - // Toggle sym modifier + // Toggle sym modifier for symbol entry (one-shot) modifierState = (modifierState == 2) ? 0 : 2; - // Also update the class-accessible ALT state for scroll-while-typing - I2CKeyboardInputDriver::setAltModifierHeld(modifierState == 2); - ILOG_DEBUG("T-Pager: Sym toggled, modifierState=%d altHeld=%d", modifierState, modifierState == 2); + // Enable scroll mode while key is physically held + I2CKeyboardInputDriver::setAltModifierHeld(true); + ILOG_DEBUG("T-Pager: Sym pressed, modifierState=%d, scroll enabled while held", modifierState); return; // Don't output a key for modifier press } From 112cb6704da74a1eeb93fc9351c418bfaae904dc Mon Sep 17 00:00:00 2001 From: katethefox Date: Wed, 14 Jan 2026 15:55:11 -0600 Subject: [PATCH 09/10] Enhance message badge readability and add keyboard event handling for message popup --- source/graphics/TFT/TFTView_480x222.cpp | 31 ++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/source/graphics/TFT/TFTView_480x222.cpp b/source/graphics/TFT/TFTView_480x222.cpp index d6bd4cd7..c6d93c61 100644 --- a/source/graphics/TFT/TFTView_480x222.cpp +++ b/source/graphics/TFT/TFTView_480x222.cpp @@ -459,6 +459,11 @@ void TFTView_480x222::init_screens(void) lv_label_set_text(messagesBadge, ""); lv_obj_set_style_text_color(messagesBadge, lv_color_hex(0xFF0000), LV_PART_MAIN); // Red lv_obj_set_style_text_font(messagesBadge, &ui_font_montserrat_20, LV_PART_MAIN); + // Add grey rounded background for readability + lv_obj_set_style_bg_color(messagesBadge, lv_color_hex(0x404040), LV_PART_MAIN); + lv_obj_set_style_bg_opa(messagesBadge, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_pad_all(messagesBadge, 2, LV_PART_MAIN); + lv_obj_set_style_radius(messagesBadge, 4, LV_PART_MAIN); lv_obj_align(messagesBadge, LV_ALIGN_TOP_RIGHT, -2, 2); lv_obj_add_flag(messagesBadge, LV_OBJ_FLAG_HIDDEN); @@ -808,6 +813,7 @@ void TFTView_480x222::ui_events_init(void) // message popup lv_obj_add_event_cb(objects.msg_popup_button, this->ui_event_MsgPopupButton, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(objects.msg_popup_button, this->ui_event_MsgPopupButton, LV_EVENT_KEY, NULL); lv_obj_add_event_cb(objects.msg_popup_panel, this->ui_event_MsgPopupButton, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(objects.msg_restore_button, this->ui_event_MsgRestoreButton, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(objects.msg_restore_panel, this->ui_event_MsgRestoreButton, LV_EVENT_CLICKED, NULL); @@ -1364,11 +1370,34 @@ void TFTView_480x222::ui_event_ChatDelButton(lv_event_t *e) /** * @brief hide msgPopupPanel on touch; goto message on button press - * + * Keyboard: Enter = navigate to chat, Backspace/ESC = dismiss */ void TFTView_480x222::ui_event_MsgPopupButton(lv_event_t *e) { + lv_event_code_t code = lv_event_get_code(e); lv_obj_t *target = lv_event_get_target_obj(e); + + // Handle keyboard events + if (code == LV_EVENT_KEY) { + uint32_t key = lv_event_get_key(e); + if (key == LV_KEY_ENTER) { + // Navigate to the chat (same as clicking button) + uint32_t channelOrNode = (unsigned long)objects.msg_popup_button->user_data; + if (channelOrNode < c_max_channels) { + uint8_t ch = (uint8_t)channelOrNode; + THIS->showMessages(ch); + } else { + uint32_t nodeNum = channelOrNode; + THIS->showMessages(nodeNum); + } + } else if (key == LV_KEY_BACKSPACE || key == LV_KEY_ESC) { + // Dismiss the popup + THIS->hideMessagePopup(); + } + return; + } + + // Handle click events if (target == objects.msg_popup_panel) { THIS->hideMessagePopup(); } else { // msg button was clicked From eac355a7c6c61e1b40858a32b569e8a5e54813b1 Mon Sep 17 00:00:00 2001 From: katethefox Date: Sat, 17 Jan 2026 14:17:03 -0600 Subject: [PATCH 10/10] Fix muted channel detection to properly handle channel 0 The previous fix incorrectly used ch > 0 to detect DMs, but channel 0 is the primary channel, not a DM indicator. Now correctly detects DMs by checking the 'to' field - a DM is when 'to' is a specific node (not 0 and not 0xFFFFFFFF broadcast). Muted channels now properly skip unread counter increment and popup. Co-Authored-By: Claude Opus 4.5 --- source/graphics/TFT/TFTView_480x222.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/source/graphics/TFT/TFTView_480x222.cpp b/source/graphics/TFT/TFTView_480x222.cpp index c6d93c61..a0aed16b 100644 --- a/source/graphics/TFT/TFTView_480x222.cpp +++ b/source/graphics/TFT/TFTView_480x222.cpp @@ -6556,11 +6556,15 @@ void TFTView_480x222::newMessage(uint32_t from, uint32_t to, uint8_t ch, const c if (!restore) { // display msg popup if not already viewing the messages if (container != activeMsgContainer || activePanel != objects.messages_panel) { - unreadMessages++; - updateUnreadMessages(); - if (activePanel != objects.messages_panel && db.uiConfig.alert_enabled && - !db.channel[ch].settings.module_settings.is_muted) { - showMessagePopup(from, to, ch, lv_label_get_text(nodes[from]->LV_OBJ_IDX(node_lbl_idx))); + // Check if channel is muted - DMs (to specific node, not broadcast) always notify + bool isDM = (to != 0 && to != 0xFFFFFFFF); + bool isMuted = !isDM && db.channel[ch].settings.module_settings.is_muted; + if (!isMuted) { + unreadMessages++; + updateUnreadMessages(); + if (activePanel != objects.messages_panel && db.uiConfig.alert_enabled) { + showMessagePopup(from, to, ch, lv_label_get_text(nodes[from]->LV_OBJ_IDX(node_lbl_idx))); + } } lv_obj_add_flag(container, LV_OBJ_FLAG_HIDDEN); }