From 878ece99f59e36d9b5b6872f2f7bdc8340cf7574 Mon Sep 17 00:00:00 2001 From: Jason P Date: Thu, 11 Dec 2025 14:08:40 -0600 Subject: [PATCH 1/2] Add files via upload --- MenuHandler.cpp | 2205 ++++++++++++++++++++++++++++++++++++++++++ NodeListRenderer.cpp | 698 +++++++++++++ NodeListRenderer.h | 67 ++ Screen.cpp | 1804 ++++++++++++++++++++++++++++++++++ Screen.h | 743 ++++++++++++++ 5 files changed, 5517 insertions(+) create mode 100644 MenuHandler.cpp create mode 100644 NodeListRenderer.cpp create mode 100644 NodeListRenderer.h create mode 100644 Screen.cpp create mode 100644 Screen.h diff --git a/MenuHandler.cpp b/MenuHandler.cpp new file mode 100644 index 00000000000..f07d29a667c --- /dev/null +++ b/MenuHandler.cpp @@ -0,0 +1,2205 @@ +#include "configuration.h" +#if HAS_SCREEN +#include "ClockRenderer.h" +#include "Default.h" +#include "GPS.h" +#include "MenuHandler.h" +#include "MeshRadio.h" +#include "MeshService.h" +#include "MessageStore.h" +#include "NodeDB.h" +#include "buzz.h" +#include "graphics/Screen.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/draw/MessageRenderer.h" +#include "graphics/draw/UIRenderer.h" +#include "input/RotaryEncoderInterruptImpl1.h" +#include "input/UpDownInterruptImpl1.h" +#include "main.h" +#include "mesh/Default.h" +#include "mesh/MeshTypes.h" +#include "modules/AdminModule.h" +#include "modules/CannedMessageModule.h" +#include "modules/KeyVerificationModule.h" + +#include "modules/TraceRouteModule.h" +#include + +extern uint16_t TFT_MESH; + +namespace graphics +{ +menuHandler::screenMenus menuHandler::menuQueue = menu_none; +bool test_enabled = false; +uint8_t test_count = 0; + +void menuHandler::loraMenu() +{ + static const char *optionsArray[] = {"Back", "Device Role", "Radio Preset", "LoRa Region"}; + enum optionsNumbers { Back = 0, device_role_picker = 1, radio_preset_picker = 2, lora_picker = 3 }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "LoRa Actions"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 4; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) { + // No action + } else if (selected == device_role_picker) { + menuHandler::menuQueue = menuHandler::device_role_picker; + } else if (selected == radio_preset_picker) { + menuHandler::menuQueue = menuHandler::radio_preset_picker; + } else if (selected == lora_picker) { + menuHandler::menuQueue = menuHandler::lora_picker; + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::OnboardMessage() +{ + static const char *optionsArray[] = {"OK", "Got it!"}; + enum optionsNumbers { OK, got }; + BannerOverlayOptions bannerOptions; +#if HAS_TFT + bannerOptions.message = "Welcome to Meshtastic!\nSwipe to navigate and\nlong press to select\nor open a menu."; +#elif defined(BUTTON_PIN) + bannerOptions.message = "Welcome to Meshtastic!\nClick to navigate and\nlong press to select\nor open a menu."; +#else + bannerOptions.message = "Welcome to Meshtastic!\nUse the Select button\nto open menus\nand make selections."; +#endif + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 2; + bannerOptions.bannerCallback = [](int selected) -> void { + menuHandler::menuQueue = menuHandler::no_timeout_lora_picker; + screen->runNow(); + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::LoraRegionPicker(uint32_t duration) +{ + static const char *optionsArray[] = {"Back", + "US", + "EU_433", + "EU_868", + "CN", + "JP", + "ANZ", + "KR", + "TW", + "RU", + "IN", + "NZ_865", + "TH", + "LORA_24", + "UA_433", + "UA_868", + "MY_433", + "MY_" + "919", + "SG_" + "923", + "PH_433", + "PH_868", + "PH_915", + "ANZ_433", + "KZ_433", + "KZ_863", + "NP_865", + "BR_902"}; + BannerOverlayOptions bannerOptions; +#if defined(M5STACK_UNITC6L) + bannerOptions.message = "LoRa Region"; +#else + bannerOptions.message = "Set the LoRa region"; +#endif + bannerOptions.durationMs = duration; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 27; + bannerOptions.InitialSelected = 0; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected != 0 && config.lora.region != _meshtastic_Config_LoRaConfig_RegionCode(selected)) { + config.lora.region = _meshtastic_Config_LoRaConfig_RegionCode(selected); + auto changes = SEGMENT_CONFIG; + + // This is needed as we wait til picking the LoRa region to generate keys for the first time. +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (!owner.is_licensed) { + bool keygenSuccess = false; + if (config.security.private_key.size == 32) { + // public key is derived from private, so this will always have the same result. + if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { + keygenSuccess = true; + } + + } else { + LOG_INFO("Generate new PKI keys"); + crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); + keygenSuccess = true; + } + if (keygenSuccess) { + config.security.public_key.size = 32; + config.security.private_key.size = 32; + owner.public_key.size = 32; + memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); + } + } +#endif + config.lora.tx_enabled = true; + initRegion(); + if (myRegion->dutyCycle < 100) { + config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit + } + + if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { + // Default broker is in use, so subscribe to the appropriate MQTT root topic for this region + sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); + changes |= SEGMENT_MODULECONFIG; + } + + service->reloadConfig(changes); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::DeviceRolePicker() +{ + static const char *optionsArray[] = {"Back", "Client", "Client Mute", "Lost and Found", "Tracker"}; + enum optionsNumbers { + Back = 0, + devicerole_client = 1, + devicerole_clientmute = 2, + devicerole_lostandfound = 3, + devicerole_tracker = 4 + }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Device Role"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 5; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) { + menuHandler::menuQueue = menuHandler::lora_Menu; + screen->runNow(); + return; + } else if (selected == devicerole_client) { + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + } else if (selected == devicerole_clientmute) { + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE; + } else if (selected == devicerole_lostandfound) { + config.device.role = meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND; + } else if (selected == devicerole_tracker) { + config.device.role = meshtastic_Config_DeviceConfig_Role_TRACKER; + } + service->reloadConfig(SEGMENT_CONFIG); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::RadioPresetPicker() +{ + static const char *optionsArray[] = {"Back", "LongSlow", "LongModerate", "LongFast", "MediumSlow", + "MediumFast", "ShortSlow", "ShortFast", "ShortTurbo"}; + enum optionsNumbers { + Back = 0, + radiopreset_LongSlow = 1, + radiopreset_LongModerate = 2, + radiopreset_LongFast = 3, + radiopreset_MediumSlow = 4, + radiopreset_MediumFast = 5, + radiopreset_ShortSlow = 6, + radiopreset_ShortFast = 7, + radiopreset_ShortTurbo = 8 + }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Radio Preset"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 9; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) { + menuHandler::menuQueue = menuHandler::lora_Menu; + screen->runNow(); + return; + } else if (selected == radiopreset_LongSlow) { + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + } else if (selected == radiopreset_LongModerate) { + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE; + } else if (selected == radiopreset_LongFast) { + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + } else if (selected == radiopreset_MediumSlow) { + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW; + } else if (selected == radiopreset_MediumFast) { + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + } else if (selected == radiopreset_ShortSlow) { + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW; + } else if (selected == radiopreset_ShortFast) { + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + } else if (selected == radiopreset_ShortTurbo) { + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + } + service->reloadConfig(SEGMENT_CONFIG); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::TwelveHourPicker() +{ + static const char *optionsArray[] = {"Back", "12-hour", "24-hour"}; + enum optionsNumbers { Back = 0, twelve = 1, twentyfour = 2 }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Time Format"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) { + menuHandler::menuQueue = menuHandler::clock_menu; + screen->runNow(); + } else if (selected == twelve) { + config.display.use_12h_clock = true; + } else { + config.display.use_12h_clock = false; + } + service->reloadConfig(SEGMENT_CONFIG); + }; + screen->showOverlayBanner(bannerOptions); +} + +// Reusable confirmation prompt function +void menuHandler::showConfirmationBanner(const char *message, std::function onConfirm) +{ + static const char *confirmOptions[] = {"No", "Yes"}; + BannerOverlayOptions confirmBanner; + confirmBanner.message = message; + confirmBanner.optionsArrayPtr = confirmOptions; + confirmBanner.optionsCount = 2; + confirmBanner.bannerCallback = [onConfirm](int confirmSelected) -> void { + if (confirmSelected == 1) { + onConfirm(); + } + }; + screen->showOverlayBanner(confirmBanner); +} + +void menuHandler::ClockFacePicker() +{ + static const char *optionsArray[] = {"Back", "Digital", "Analog"}; + enum optionsNumbers { Back = 0, Digital = 1, Analog = 2 }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Which Face?"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) { + menuHandler::menuQueue = menuHandler::clock_menu; + screen->runNow(); + } else if (selected == Digital) { + uiconfig.is_clockface_analog = false; + saveUIConfig(); + screen->setFrames(Screen::FOCUS_CLOCK); + } else { + uiconfig.is_clockface_analog = true; + saveUIConfig(); + screen->setFrames(Screen::FOCUS_CLOCK); + } + }; + bannerOptions.InitialSelected = uiconfig.is_clockface_analog ? 2 : 1; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::TZPicker() +{ + static const char *optionsArray[] = {"Back", + "US/Hawaii", + "US/Alaska", + "US/Pacific", + "US/Arizona", + "US/Mountain", + "US/Central", + "US/Eastern", + "BR/Brasilia", + "UTC", + "EU/Western", + "EU/" + "Central", + "EU/Eastern", + "Asia/Kolkata", + "Asia/Hong_Kong", + "AU/AWST", + "AU/ACST", + "AU/AEST", + "Pacific/NZ"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Pick Timezone"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 19; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuHandler::menuQueue = menuHandler::clock_menu; + screen->runNow(); + } else if (selected == 1) { // Hawaii + strncpy(config.device.tzdef, "HST10", sizeof(config.device.tzdef)); + } else if (selected == 2) { // Alaska + strncpy(config.device.tzdef, "AKST9AKDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 3) { // Pacific + strncpy(config.device.tzdef, "PST8PDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 4) { // Arizona + strncpy(config.device.tzdef, "MST7", sizeof(config.device.tzdef)); + } else if (selected == 5) { // Mountain + strncpy(config.device.tzdef, "MST7MDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 6) { // Central + strncpy(config.device.tzdef, "CST6CDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 7) { // Eastern + strncpy(config.device.tzdef, "EST5EDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); + } else if (selected == 8) { // Brazil + strncpy(config.device.tzdef, "BRT3", sizeof(config.device.tzdef)); + } else if (selected == 9) { // UTC + strncpy(config.device.tzdef, "UTC0", sizeof(config.device.tzdef)); + } else if (selected == 10) { // EU/Western + strncpy(config.device.tzdef, "GMT0BST,M3.5.0/1,M10.5.0", sizeof(config.device.tzdef)); + } else if (selected == 11) { // EU/Central + strncpy(config.device.tzdef, "CET-1CEST,M3.5.0,M10.5.0/3", sizeof(config.device.tzdef)); + } else if (selected == 12) { // EU/Eastern + strncpy(config.device.tzdef, "EET-2EEST,M3.5.0/3,M10.5.0/4", sizeof(config.device.tzdef)); + } else if (selected == 13) { // Asia/Kolkata + strncpy(config.device.tzdef, "IST-5:30", sizeof(config.device.tzdef)); + } else if (selected == 14) { // China + strncpy(config.device.tzdef, "HKT-8", sizeof(config.device.tzdef)); + } else if (selected == 15) { // AU/AWST + strncpy(config.device.tzdef, "AWST-8", sizeof(config.device.tzdef)); + } else if (selected == 16) { // AU/ACST + strncpy(config.device.tzdef, "ACST-9:30ACDT,M10.1.0,M4.1.0/3", sizeof(config.device.tzdef)); + } else if (selected == 17) { // AU/AEST + strncpy(config.device.tzdef, "AEST-10AEDT,M10.1.0,M4.1.0/3", sizeof(config.device.tzdef)); + } else if (selected == 18) { // NZ + strncpy(config.device.tzdef, "NZST-12NZDT,M9.5.0,M4.1.0/3", sizeof(config.device.tzdef)); + } + if (selected != 0) { + setenv("TZ", config.device.tzdef, 1); + service->reloadConfig(SEGMENT_CONFIG); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::clockMenu() +{ +#if defined(M5STACK_UNITC6L) + static const char *optionsArray[] = {"Back", "Time Format", "Timezone"}; +#else + static const char *optionsArray[] = {"Back", "Clock Face", "Time Format", "Timezone"}; +#endif + enum optionsNumbers { Back = 0, Clock = 1, Time = 2, Timezone = 3 }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Clock Action"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 4; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Clock) { + menuHandler::menuQueue = menuHandler::clock_face_picker; + screen->runNow(); + } else if (selected == Time) { + menuHandler::menuQueue = menuHandler::twelve_hour_picker; + screen->runNow(); + } else if (selected == Timezone) { + menuHandler::menuQueue = menuHandler::TZ_picker; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} +void menuHandler::messageResponseMenu() +{ + enum optionsNumbers { Back = 0, ViewMode, DeleteAll, DeleteOldest, ReplyMenu, Aloud, enumEnd }; + + static const char *optionsArray[enumEnd]; + static int optionsEnumArray[enumEnd]; + int options = 0; + + auto mode = graphics::MessageRenderer::getThreadMode(); + + optionsArray[options] = "Back"; + optionsEnumArray[options++] = Back; + + // New Reply submenu (replaces Preset and Freetext directly in this menu) + optionsArray[options] = "Reply"; + optionsEnumArray[options++] = ReplyMenu; + + optionsArray[options] = "View Chats"; + optionsEnumArray[options++] = ViewMode; + + // Delete submenu + optionsArray[options] = "Delete"; + optionsEnumArray[options++] = 900; + +#ifdef HAS_I2S + optionsArray[options] = "Read Aloud"; + optionsEnumArray[options++] = Aloud; +#endif + + BannerOverlayOptions bannerOptions; +#if defined(M5STACK_UNITC6L) + bannerOptions.message = "Message"; +#else + bannerOptions.message = "Message Action"; +#endif + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + LOG_DEBUG("messageResponseMenu: selected %d", selected); + + auto mode = graphics::MessageRenderer::getThreadMode(); + int ch = graphics::MessageRenderer::getThreadChannel(); + uint32_t peer = graphics::MessageRenderer::getThreadPeer(); + + LOG_DEBUG("[ReplyCtx] mode=%d ch=%d peer=0x%08x", (int)mode, ch, (unsigned int)peer); + + if (selected == ViewMode) { + menuHandler::menuQueue = menuHandler::message_viewmode_menu; + screen->runNow(); + + // Reply submenu + } else if (selected == ReplyMenu) { + menuHandler::menuQueue = menuHandler::reply_menu; + screen->runNow(); + + // Delete submenu + } else if (selected == 900) { + menuHandler::menuQueue = menuHandler::delete_messages_menu; + screen->runNow(); + + // Delete oldest FIRST (only change) + } else if (selected == DeleteOldest) { + auto mode = graphics::MessageRenderer::getThreadMode(); + int ch = graphics::MessageRenderer::getThreadChannel(); + uint32_t peer = graphics::MessageRenderer::getThreadPeer(); + + if (mode == graphics::MessageRenderer::ThreadMode::ALL) { + // Global oldest + messageStore.deleteOldestMessage(); + } else if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { + // Oldest in current channel + messageStore.deleteOldestMessageInChannel(ch); + } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { + // Oldest in current DM + messageStore.deleteOldestMessageWithPeer(peer); + } + + // Delete all messages + } else if (selected == DeleteAll) { + messageStore.clearAllMessages(); + graphics::MessageRenderer::clearThreadRegistries(); + graphics::MessageRenderer::clearMessageCache(); + +#ifdef HAS_I2S + } else if (selected == Aloud) { + const meshtastic_MeshPacket &mp = devicestate.rx_text_message; + const char *msg = reinterpret_cast(mp.decoded.payload.bytes); + audioThread->readAloud(msg); +#endif + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::replyMenu() +{ + enum replyOptions { Back = 0, ReplyPreset, ReplyFreetext, enumEnd }; + + static const char *optionsArray[enumEnd]; + static int optionsEnumArray[enumEnd]; + int options = 0; + + // Back + optionsArray[options] = "Back"; + optionsEnumArray[options++] = Back; + + // Preset reply + optionsArray[options] = "With Preset"; + optionsEnumArray[options++] = ReplyPreset; + + // Freetext reply (only when keyboard exists) + if (kb_found) { + optionsArray[options] = "With Freetext"; + optionsEnumArray[options++] = ReplyFreetext; + } + + BannerOverlayOptions bannerOptions; + + // Dynamic title based on thread mode + auto mode = graphics::MessageRenderer::getThreadMode(); + if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { + bannerOptions.message = "Reply to Channel"; + } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { + bannerOptions.message = "Reply to DM"; + } else { + // View All + bannerOptions.message = "Reply to Last Msg"; + } + + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = options; + bannerOptions.InitialSelected = 1; + + bannerOptions.bannerCallback = [](int selected) -> void { + auto mode = graphics::MessageRenderer::getThreadMode(); + int ch = graphics::MessageRenderer::getThreadChannel(); + uint32_t peer = graphics::MessageRenderer::getThreadPeer(); + + if (selected == Back) { + menuHandler::menuQueue = menuHandler::message_response_menu; + screen->runNow(); + return; + } + + // Preset reply + if (selected == ReplyPreset) { + + if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, ch); + + } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { + cannedMessageModule->LaunchWithDestination(peer); + + } else { + // Fallback for last received message + if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel); + } else { + cannedMessageModule->LaunchWithDestination(devicestate.rx_text_message.from); + } + } + + return; + } + + // Freetext reply + if (selected == ReplyFreetext) { + + if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { + cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, ch); + + } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { + cannedMessageModule->LaunchFreetextWithDestination(peer); + + } else { + // Fallback for last received message + if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { + cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel); + } else { + cannedMessageModule->LaunchFreetextWithDestination(devicestate.rx_text_message.from); + } + } + + return; + } + }; + screen->showOverlayBanner(bannerOptions); +} +void menuHandler::deleteMessagesMenu() +{ + enum optionsNumbers { Back = 0, DeleteOldest, DeleteThis, DeleteAll, enumEnd }; + + static const char *optionsArray[enumEnd]; + static int optionsEnumArray[enumEnd]; + int options = 0; + + auto mode = graphics::MessageRenderer::getThreadMode(); + + optionsArray[options] = "Back"; + optionsEnumArray[options++] = Back; + + optionsArray[options] = "Delete Oldest"; + optionsEnumArray[options++] = DeleteOldest; + + // If viewing ALL chats → hide “Delete This Chat” + if (mode != graphics::MessageRenderer::ThreadMode::ALL) { + optionsArray[options] = "Delete This Chat"; + optionsEnumArray[options++] = DeleteThis; + } +#if defined(M5STACK_UNITC6L) + optionsArray[options] = "Delete All"; +#else + optionsArray[options] = "Delete All Chats"; +#endif + optionsEnumArray[options++] = DeleteAll; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Delete Messages"; + + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [mode](int selected) -> void { + int ch = graphics::MessageRenderer::getThreadChannel(); + uint32_t peer = graphics::MessageRenderer::getThreadPeer(); + + if (selected == Back) { + menuHandler::menuQueue = menuHandler::message_response_menu; + screen->runNow(); + return; + } + + if (selected == DeleteAll) { + LOG_INFO("Deleting all messages"); + messageStore.clearAllMessages(); + graphics::MessageRenderer::clearThreadRegistries(); + graphics::MessageRenderer::clearMessageCache(); + return; + } + + if (selected == DeleteOldest) { + LOG_INFO("Deleting oldest message"); + + if (mode == graphics::MessageRenderer::ThreadMode::ALL) { + messageStore.deleteOldestMessage(); + } else if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { + messageStore.deleteOldestMessageInChannel(ch); + } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { + messageStore.deleteOldestMessageWithPeer(peer); + } + + return; + } + + // This only appears in non-ALL modes + if (selected == DeleteThis) { + LOG_INFO("Deleting all messages in this thread"); + + if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { + messageStore.deleteAllMessagesInChannel(ch); + } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { + messageStore.deleteAllMessagesWithPeer(peer); + } + + return; + } + }; + + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::messageViewModeMenu() +{ + auto encodeChannelId = [](int ch) -> int { return 100 + ch; }; + auto isChannelSel = [](int id) -> bool { return id >= 100 && id < 200; }; + + static std::vector labels; + static std::vector ids; + static std::vector idToPeer; // DM lookup + + labels.clear(); + ids.clear(); + idToPeer.clear(); + + labels.push_back("Back"); + ids.push_back(-1); + labels.push_back("View All Chats"); + ids.push_back(-2); + + // Channels with messages + for (int ch = 0; ch < 8; ++ch) { + auto msgs = messageStore.getChannelMessages((uint8_t)ch); + if (!msgs.empty()) { + char buf[40]; + const char *cname = channels.getName(ch); + snprintf(buf, sizeof(buf), cname && cname[0] ? "#%s" : "#Ch%d", cname ? cname : "", ch); + labels.push_back(buf); + ids.push_back(encodeChannelId(ch)); + LOG_DEBUG("messageViewModeMenu: Added live channel %s (id=%d)", buf, encodeChannelId(ch)); + } + } + + // Registry channels + for (int ch : graphics::MessageRenderer::getSeenChannels()) { + if (ch < 0 || ch >= 8) + continue; + auto msgs = messageStore.getChannelMessages((uint8_t)ch); + if (msgs.empty()) + continue; + int enc = encodeChannelId(ch); + if (std::find(ids.begin(), ids.end(), enc) == ids.end()) { + char buf[40]; + const char *cname = channels.getName(ch); + snprintf(buf, sizeof(buf), cname && cname[0] ? "#%s" : "#Ch%d", cname ? cname : "", ch); + labels.push_back(buf); + ids.push_back(enc); + LOG_DEBUG("messageViewModeMenu: Added registry channel %s (id=%d)", buf, enc); + } + } + + // Gather unique peers + auto dms = messageStore.getDirectMessages(); + std::vector uniquePeers; + for (auto &m : dms) { + uint32_t peer = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender; + if (peer != nodeDB->getNodeNum() && std::find(uniquePeers.begin(), uniquePeers.end(), peer) == uniquePeers.end()) + uniquePeers.push_back(peer); + } + for (uint32_t peer : graphics::MessageRenderer::getSeenPeers()) { + if (peer != nodeDB->getNodeNum() && std::find(uniquePeers.begin(), uniquePeers.end(), peer) == uniquePeers.end()) + uniquePeers.push_back(peer); + } + std::sort(uniquePeers.begin(), uniquePeers.end()); + + // Encode peers + for (size_t i = 0; i < uniquePeers.size(); ++i) { + uint32_t peer = uniquePeers[i]; + auto node = nodeDB->getMeshNode(peer); + std::string name; + if (node && node->has_user) + name = sanitizeString(node->user.long_name).substr(0, 15); + else { + char buf[20]; + snprintf(buf, sizeof(buf), "Node %08X", peer); + name = buf; + } + labels.push_back("@" + name); + int encPeer = 1000 + (int)idToPeer.size(); + ids.push_back(encPeer); + idToPeer.push_back(peer); + LOG_DEBUG("messageViewModeMenu: Added DM %s peer=0x%08x id=%d", name.c_str(), (unsigned int)peer, encPeer); + } + + // Active ID + int activeId = -2; + auto mode = graphics::MessageRenderer::getThreadMode(); + if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) + activeId = encodeChannelId(graphics::MessageRenderer::getThreadChannel()); + else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { + uint32_t cur = graphics::MessageRenderer::getThreadPeer(); + for (size_t i = 0; i < idToPeer.size(); ++i) + if (idToPeer[i] == cur) { + activeId = 1000 + (int)i; + break; + } + } + + LOG_DEBUG("messageViewModeMenu: Active thread id=%d", activeId); + + // Build banner + static std::vector options; + static std::vector optionIds; + options.clear(); + optionIds.clear(); + + int initialIndex = 0; + for (size_t i = 0; i < labels.size(); i++) { + options.push_back(labels[i].c_str()); + optionIds.push_back(ids[i]); + if (ids[i] == activeId) + initialIndex = (int)i; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Select Conversation"; + bannerOptions.optionsArrayPtr = options.data(); + bannerOptions.optionsEnumPtr = optionIds.data(); + bannerOptions.optionsCount = options.size(); + bannerOptions.InitialSelected = initialIndex; + + bannerOptions.bannerCallback = [=](int selected) -> void { + LOG_DEBUG("messageViewModeMenu: selected=%d", selected); + if (selected == -1) { + menuHandler::menuQueue = menuHandler::message_response_menu; + screen->runNow(); + } else if (selected == -2) { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::ALL); + } else if (isChannelSel(selected)) { + int ch = selected - 100; + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::CHANNEL, ch); + } else if (selected >= 1000) { + int idx = selected - 1000; + if (idx >= 0 && (size_t)idx < idToPeer.size()) { + uint32_t peer = idToPeer[idx]; + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, peer); + } + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::homeBaseMenu() +{ + enum optionsNumbers { Back, Backlight, Position, Preset, Freetext, Sleep, enumEnd }; + + static const char *optionsArray[enumEnd] = {"Back"}; + static int optionsEnumArray[enumEnd] = {Back}; + int options = 1; + +#if defined(PIN_EINK_EN) || defined(PCA_PIN_EINK_EN) + optionsArray[options] = "Toggle Backlight"; + optionsEnumArray[options++] = Backlight; +#else + optionsArray[options] = "Sleep Screen"; + optionsEnumArray[options++] = Sleep; +#endif + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + optionsArray[options] = "Send Position"; + } else { + optionsArray[options] = "Send Node Info"; + } + optionsEnumArray[options++] = Position; + + BannerOverlayOptions bannerOptions; +#if defined(M5STACK_UNITC6L) + bannerOptions.message = "Home"; +#else + bannerOptions.message = "Home Action"; +#endif + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Backlight) { +#if defined(PIN_EINK_EN) + if (uiconfig.screen_brightness == 1) { + uiconfig.screen_brightness = 0; + digitalWrite(PIN_EINK_EN, LOW); + } else { + uiconfig.screen_brightness = 1; + digitalWrite(PIN_EINK_EN, HIGH); + } + saveUIConfig(); +#elif defined(PCA_PIN_EINK_EN) + if (uiconfig.screen_brightness > 0) { + uiconfig.screen_brightness = 0; + io.digitalWrite(PCA_PIN_EINK_EN, LOW); + } else { + uiconfig.screen_brightness = 1; + io.digitalWrite(PCA_PIN_EINK_EN, HIGH); + } + saveUIConfig(); +#endif + } else if (selected == Sleep) { + screen->setOn(false); + } else if (selected == Position) { + InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_SEND_PING, .kbchar = 0, .touchX = 0, .touchY = 0}; + inputBroker->injectInputEvent(&event); + } else if (selected == Preset) { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); + } else if (selected == Freetext) { + cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::textMessageMenu() +{ + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); +} + +void menuHandler::textMessageBaseMenu() +{ + enum optionsNumbers { Back, Preset, Freetext, enumEnd }; + + static const char *optionsArray[enumEnd] = {"Back"}; + static int optionsEnumArray[enumEnd] = {Back}; + int options = 1; + optionsArray[options] = "New Preset Msg"; + optionsEnumArray[options++] = Preset; + if (kb_found) { + optionsArray[options] = "New Freetext Msg"; + optionsEnumArray[options++] = Freetext; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Message Action"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Preset) { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); + } else if (selected == Freetext) { + cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::systemBaseMenu() +{ + enum optionsNumbers { Back, Notifications, ScreenOptions, Bluetooth, WiFiToggle, PowerMenu, Test, enumEnd }; + static const char *optionsArray[enumEnd] = {"Back"}; + static int optionsEnumArray[enumEnd] = {Back}; + int options = 1; + + optionsArray[options] = "Notifications"; + optionsEnumArray[options++] = Notifications; + optionsArray[options] = "Display Options"; + optionsEnumArray[options++] = ScreenOptions; + +#if defined(M5STACK_UNITC6L) + optionsArray[options] = "Bluetooth"; +#else + optionsArray[options] = "Bluetooth Toggle"; +#endif + optionsEnumArray[options++] = Bluetooth; +#if HAS_WIFI && !defined(ARCH_PORTDUINO) + optionsArray[options] = "WiFi Toggle"; + optionsEnumArray[options++] = WiFiToggle; +#endif +#if defined(M5STACK_UNITC6L) + optionsArray[options] = "Power"; +#else + optionsArray[options] = "Reboot/Shutdown"; +#endif + optionsEnumArray[options++] = PowerMenu; + + if (test_enabled) { + optionsArray[options] = "Test Menu"; + optionsEnumArray[options++] = Test; + } + + BannerOverlayOptions bannerOptions; +#if defined(M5STACK_UNITC6L) + bannerOptions.message = "System"; +#else + bannerOptions.message = "System Action"; +#endif + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Notifications) { + menuHandler::menuQueue = menuHandler::notifications_menu; + screen->runNow(); + } else if (selected == ScreenOptions) { + menuHandler::menuQueue = menuHandler::screen_options_menu; + screen->runNow(); + } else if (selected == PowerMenu) { + menuHandler::menuQueue = menuHandler::power_menu; + screen->runNow(); + } else if (selected == Test) { + menuHandler::menuQueue = menuHandler::test_menu; + screen->runNow(); + } else if (selected == Bluetooth) { + menuQueue = bluetooth_toggle_menu; + screen->runNow(); +#if HAS_WIFI && !defined(ARCH_PORTDUINO) + } else if (selected == WiFiToggle) { + menuQueue = wifi_toggle_menu; + screen->runNow(); +#endif + } else if (selected == Back && !test_enabled) { + test_count++; + if (test_count > 4) { + test_enabled = true; + } + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::favoriteBaseMenu() +{ + enum optionsNumbers { Back, Preset, Freetext, GoToChat, Remove, TraceRoute, enumEnd }; + + static const char *optionsArray[enumEnd] = {"Back"}; + static int optionsEnumArray[enumEnd] = {Back}; + int options = 1; + + // Only show "View Conversation" if a message exists with this node + uint32_t peer = graphics::UIRenderer::currentFavoriteNodeNum; + bool hasConversation = false; + for (const auto &m : messageStore.getMessages()) { + if ((m.sender == peer || m.dest == peer)) { + hasConversation = true; + break; + } + } + if (hasConversation) { + optionsArray[options] = "Go To Chat"; + optionsEnumArray[options++] = GoToChat; + } +#if defined(M5STACK_UNITC6L) + optionsArray[options] = "New Preset"; +#else + optionsArray[options] = "New Preset Msg"; +#endif + optionsEnumArray[options++] = Preset; + + if (kb_found) { + optionsArray[options] = "New Freetext Msg"; + optionsEnumArray[options++] = Freetext; + } + +#if !defined(M5STACK_UNITC6L) + optionsArray[options] = "Trace Route"; + optionsEnumArray[options++] = TraceRoute; +#endif + optionsArray[options] = "Remove Favorite"; + optionsEnumArray[options++] = Remove; + + BannerOverlayOptions bannerOptions; +#if defined(M5STACK_UNITC6L) + bannerOptions.message = "Favorites"; +#else + bannerOptions.message = "Favorites Action"; +#endif + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Preset) { + cannedMessageModule->LaunchWithDestination(graphics::UIRenderer::currentFavoriteNodeNum); + } else if (selected == Freetext) { + cannedMessageModule->LaunchFreetextWithDestination(graphics::UIRenderer::currentFavoriteNodeNum); + } + // Handle new Go To Thread action + else if (selected == GoToChat) { + // Switch thread to direct conversation with this node + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, + graphics::UIRenderer::currentFavoriteNodeNum); + + // Manually create and send a UIFrameEvent to trigger the jump + UIFrameEvent evt; + evt.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE; + screen->handleUIFrameEvent(&evt); + } else if (selected == Remove) { + menuHandler::menuQueue = menuHandler::remove_favorite; + screen->runNow(); + } else if (selected == TraceRoute) { + if (traceRouteModule) { + traceRouteModule->launch(graphics::UIRenderer::currentFavoriteNodeNum); + } + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::positionBaseMenu() +{ + enum optionsNumbers { Back, GPSToggle, GPSFormat, CompassMenu, CompassCalibrate, enumEnd }; + + static const char *optionsArray[enumEnd] = {"Back", "GPS Toggle", "GPS Format", "Compass"}; + static int optionsEnumArray[enumEnd] = {Back, GPSToggle, GPSFormat, CompassMenu}; + int options = 4; + + if (accelerometerThread) { + optionsArray[options] = "Compass Calibrate"; + optionsEnumArray[options++] = CompassCalibrate; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Position Action"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == GPSToggle) { + menuQueue = gps_toggle_menu; + screen->runNow(); + } else if (selected == GPSFormat) { + menuQueue = gps_format_menu; + screen->runNow(); + } else if (selected == CompassMenu) { + menuQueue = compass_point_north_menu; + screen->runNow(); + } else if (selected == CompassCalibrate) { + accelerometerThread->calibrate(30); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::nodeListMenu() +{ + enum optionsNumbers { Back, Favorite, TraceRoute, Verify, Reset, NodeNameLength, enumEnd }; + static const char *optionsArray[enumEnd] = {"Back"}; + static int optionsEnumArray[enumEnd] = {Back}; + int options = 1; + + optionsArray[options] = "Add Favorite"; + optionsEnumArray[options++] = Favorite; + optionsArray[options] = "Trace Route"; + optionsEnumArray[options++] = TraceRoute; + +#if !defined(M5STACK_UNITC6L) + optionsArray[options] = "Key Verification"; + optionsEnumArray[options++] = Verify; +#endif + +#if defined(T_DECK) || defined(T_LORA_PAGER) || defined(HACKADAY_COMMUNICATOR) + optionsArray[options] = "Show Long/Short Name"; + optionsEnumArray[options++] = NodeNameLength; +#endif + optionsArray[options] = "Reset NodeDB"; + optionsEnumArray[options++] = Reset; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Node Action"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Favorite) { + menuQueue = add_favorite; + screen->runNow(); + } else if (selected == Verify) { + menuQueue = key_verification_init; + screen->runNow(); + } else if (selected == Reset) { + menuQueue = reset_node_db_menu; + screen->runNow(); + } else if (selected == TraceRoute) { + menuQueue = trace_route_menu; + screen->runNow(); + } else if (selected == NodeNameLength) { + menuHandler::menuQueue = menuHandler::node_name_length_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::nodeNameLengthMenu() +{ + enum OptionsNumbers { Back, Long, Short }; + static const char *optionsArray[] = {"Back", "Long", "Short"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Node Name Length"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Long) { + // Set names to long + LOG_INFO("Setting names to long"); + config.display.use_long_node_name = true; + } else if (selected == Short) { + // Set names to short + LOG_INFO("Setting names to short"); + config.display.use_long_node_name = false; + } else if (selected == Back) { + menuQueue = screen_options_menu; + screen->runNow(); + } + }; + bannerOptions.InitialSelected = config.display.use_long_node_name == true ? 1 : 2; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::resetNodeDBMenu() +{ + static const char *optionsArray[] = {"Back", "Reset All", "Preserve Favorites"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Confirm Reset NodeDB"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 1 || selected == 2) { + disableBluetooth(); + screen->setFrames(Screen::FOCUS_DEFAULT); + } + if (selected == 1) { + LOG_INFO("Initiate node-db reset"); + nodeDB->resetNodes(); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + } else if (selected == 2) { + LOG_INFO("Initiate node-db reset but keeping favorites"); + nodeDB->resetNodes(1); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::compassNorthMenu() +{ + enum optionsNumbers { Back, Dynamic, Fixed, Freeze }; + static const char *optionsArray[] = {"Back", "Dynamic", "Fixed Ring", "Freeze Heading"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "North Directions?"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 4; + bannerOptions.InitialSelected = uiconfig.compass_mode + 1; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Dynamic) { + if (uiconfig.compass_mode != meshtastic_CompassMode_DYNAMIC) { + uiconfig.compass_mode = meshtastic_CompassMode_DYNAMIC; + saveUIConfig(); + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + } else if (selected == Fixed) { + if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) { + uiconfig.compass_mode = meshtastic_CompassMode_FIXED_RING; + saveUIConfig(); + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + } else if (selected == Freeze) { + if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) { + uiconfig.compass_mode = meshtastic_CompassMode_FREEZE_HEADING; + saveUIConfig(); + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + } else if (selected == Back) { + menuQueue = position_base_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +#if !MESHTASTIC_EXCLUDE_GPS +void menuHandler::GPSToggleMenu() +{ + + static const char *optionsArray[] = {"Back", "Enabled", "Disabled"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Toggle GPS"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 1) { + config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED; + playGPSEnableBeep(); + gps->enable(); + service->reloadConfig(SEGMENT_CONFIG); + } else if (selected == 2) { + config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED; + playGPSDisableBeep(); + gps->disable(); + service->reloadConfig(SEGMENT_CONFIG); + } else { + menuQueue = position_base_menu; + screen->runNow(); + } + }; + bannerOptions.InitialSelected = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED ? 1 : 2; + screen->showOverlayBanner(bannerOptions); +} +void menuHandler::GPSFormatMenu() +{ + + static const char *optionsArray[] = {"Back", + isHighResolution ? "Decimal Degrees" : "DEC", + isHighResolution ? "Degrees Minutes Seconds" : "DMS", + isHighResolution ? "Universal Transverse Mercator" : "UTM", + isHighResolution ? "Military Grid Reference System" : "MGRS", + isHighResolution ? "Open Location Code" : "OLC", + isHighResolution ? "Ordnance Survey Grid Ref" : "OSGR", + isHighResolution ? "Maidenhead Locator" : "MLS"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "GPS Format"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 8; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 1) { + uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_DEC; + saveUIConfig(); + service->reloadConfig(SEGMENT_CONFIG); + } else if (selected == 2) { + uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS; + saveUIConfig(); + service->reloadConfig(SEGMENT_CONFIG); + } else if (selected == 3) { + uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_UTM; + saveUIConfig(); + service->reloadConfig(SEGMENT_CONFIG); + } else if (selected == 4) { + uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_MGRS; + saveUIConfig(); + service->reloadConfig(SEGMENT_CONFIG); + } else if (selected == 5) { + uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC; + saveUIConfig(); + service->reloadConfig(SEGMENT_CONFIG); + } else if (selected == 6) { + uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_OSGR; + saveUIConfig(); + service->reloadConfig(SEGMENT_CONFIG); + } else if (selected == 7) { + uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS; + saveUIConfig(); + service->reloadConfig(SEGMENT_CONFIG); + } else { + menuQueue = position_base_menu; + screen->runNow(); + } + }; + bannerOptions.InitialSelected = uiconfig.gps_format + 1; + screen->showOverlayBanner(bannerOptions); +} +#endif + +void menuHandler::BluetoothToggleMenu() +{ + static const char *optionsArray[] = {"Back", "Enabled", "Disabled"}; + BannerOverlayOptions bannerOptions; +#if defined(M5STACK_UNITC6L) + bannerOptions.message = "Bluetooth"; +#else + bannerOptions.message = "Toggle Bluetooth"; +#endif + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) + return; + else if (selected != (config.bluetooth.enabled ? 1 : 2)) { + InputEvent event = {.inputEvent = (input_broker_event)170, .kbchar = 170, .touchX = 0, .touchY = 0}; + inputBroker->injectInputEvent(&event); + } + }; + bannerOptions.InitialSelected = config.bluetooth.enabled ? 1 : 2; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::BuzzerModeMenu() +{ + static const char *optionsArray[] = {"All Enabled", "Disabled", "Notifications", "System Only", "DMs Only"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Buzzer Mode"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 5; + bannerOptions.bannerCallback = [](int selected) -> void { + config.device.buzzer_mode = (meshtastic_Config_DeviceConfig_BuzzerMode)selected; + service->reloadConfig(SEGMENT_CONFIG); + }; + bannerOptions.InitialSelected = config.device.buzzer_mode; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::BrightnessPickerMenu() +{ + static const char *optionsArray[] = {"Back", "Low", "Medium", "High"}; + + // Get current brightness level to set initial selection + int currentSelection = 1; // Default to Medium + if (uiconfig.screen_brightness >= 255) { + currentSelection = 3; // Very High + } else if (uiconfig.screen_brightness >= 128) { + currentSelection = 2; // High + } else { + currentSelection = 1; // Medium + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Brightness"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 4; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 1) { // Medium + uiconfig.screen_brightness = 64; + } else if (selected == 2) { // High + uiconfig.screen_brightness = 128; + } else if (selected == 3) { // Very High + uiconfig.screen_brightness = 255; + } + + if (selected != 0) { // Not "Back" + // Apply brightness immediately +#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) + // For HELTEC devices, use analogWrite to control backlight + analogWrite(VTFT_LEDA, uiconfig.screen_brightness); +#elif defined(ST7789_CS) || defined(ST7796_CS) + static_cast(screen->getDisplayDevice())->setDisplayBrightness(uiconfig.screen_brightness); +#elif defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) + screen->getDisplayDevice()->setBrightness(uiconfig.screen_brightness); +#endif + + // Save to device + saveUIConfig(); + + LOG_INFO("Screen brightness set to %d", uiconfig.screen_brightness); + } + }; + bannerOptions.InitialSelected = currentSelection; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::switchToMUIMenu() +{ + static const char *optionsArray[] = {"No", "Yes"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Switch to MUI?"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 2; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 1) { + config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR; + config.bluetooth.enabled = false; + service->reloadConfig(SEGMENT_CONFIG); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::TFTColorPickerMenu(OLEDDisplay *display) +{ + static const char *optionsArray[] = { + "Back", "Default", "Meshtastic Green", "Yellow", "Red", "Orange", "Purple", "Blue", "Teal", "Cyan", "Ice", "Pink", + "White", "Gray"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Select Screen Color"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 14; + bannerOptions.bannerCallback = [display](int selected) -> void { +#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \ + HAS_TFT || defined(HACKADAY_COMMUNICATOR) + uint8_t TFT_MESH_r = 0; + uint8_t TFT_MESH_g = 0; + uint8_t TFT_MESH_b = 0; + if (selected == 1) { + LOG_INFO("Setting color to system default or defined variant"); + // Given just before we set all these to zero, we will allow this to go through + } else if (selected == 2) { + LOG_INFO("Setting color to Meshtastic Green"); + TFT_MESH_r = 103; + TFT_MESH_g = 234; + TFT_MESH_b = 148; + } else if (selected == 3) { + LOG_INFO("Setting color to Yellow"); + TFT_MESH_r = 255; + TFT_MESH_g = 255; + TFT_MESH_b = 128; + } else if (selected == 4) { + LOG_INFO("Setting color to Red"); + TFT_MESH_r = 255; + TFT_MESH_g = 64; + TFT_MESH_b = 64; + } else if (selected == 5) { + LOG_INFO("Setting color to Orange"); + TFT_MESH_r = 255; + TFT_MESH_g = 160; + TFT_MESH_b = 20; + } else if (selected == 6) { + LOG_INFO("Setting color to Purple"); + TFT_MESH_r = 204; + TFT_MESH_g = 153; + TFT_MESH_b = 255; + } else if (selected == 7) { + LOG_INFO("Setting color to Blue"); + TFT_MESH_r = 0; + TFT_MESH_g = 0; + TFT_MESH_b = 255; + } else if (selected == 8) { + LOG_INFO("Setting color to Teal"); + TFT_MESH_r = 16; + TFT_MESH_g = 102; + TFT_MESH_b = 102; + } else if (selected == 9) { + LOG_INFO("Setting color to Cyan"); + TFT_MESH_r = 0; + TFT_MESH_g = 255; + TFT_MESH_b = 255; + } else if (selected == 10) { + LOG_INFO("Setting color to Ice"); + TFT_MESH_r = 173; + TFT_MESH_g = 216; + TFT_MESH_b = 230; + } else if (selected == 11) { + LOG_INFO("Setting color to Pink"); + TFT_MESH_r = 255; + TFT_MESH_g = 105; + TFT_MESH_b = 180; + } else if (selected == 12) { + LOG_INFO("Setting color to White"); + TFT_MESH_r = 255; + TFT_MESH_g = 255; + TFT_MESH_b = 255; + } else if (selected == 13) { + LOG_INFO("Setting color to Gray"); + TFT_MESH_r = 128; + TFT_MESH_g = 128; + TFT_MESH_b = 128; + } else { + menuQueue = system_base_menu; + screen->runNow(); + } + + if (selected != 0) { + display->setColor(BLACK); + display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); + display->setColor(WHITE); + + if (TFT_MESH_r == 0 && TFT_MESH_g == 0 && TFT_MESH_b == 0) { +#ifdef TFT_MESH_OVERRIDE + TFT_MESH = TFT_MESH_OVERRIDE; +#else + TFT_MESH = COLOR565(0x67, 0xEA, 0x94); +#endif + } else { + TFT_MESH = COLOR565(TFT_MESH_r, TFT_MESH_g, TFT_MESH_b); + } + +#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) + static_cast(screen->getDisplayDevice())->setRGB(TFT_MESH); +#endif + + screen->setFrames(graphics::Screen::FOCUS_SYSTEM); + if (TFT_MESH_r == 0 && TFT_MESH_g == 0 && TFT_MESH_b == 0) { + uiconfig.screen_rgb_color = 0; + } else { + uiconfig.screen_rgb_color = (TFT_MESH_r << 16) | (TFT_MESH_g << 8) | TFT_MESH_b; + } + LOG_INFO("Storing Value of %d to uiconfig.screen_rgb_color", uiconfig.screen_rgb_color); + saveUIConfig(); + } +#endif + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::rebootMenu() +{ + static const char *optionsArray[] = {"Back", "Confirm"}; + BannerOverlayOptions bannerOptions; +#if defined(M5STACK_UNITC6L) + bannerOptions.message = "Reboot"; +#else + bannerOptions.message = "Reboot Device?"; +#endif + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 2; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 1) { + IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0)); + nodeDB->saveToDisk(); + messageStore.saveToFlash(); + rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + } else { + menuQueue = power_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::shutdownMenu() +{ + static const char *optionsArray[] = {"Back", "Confirm"}; + BannerOverlayOptions bannerOptions; +#if defined(M5STACK_UNITC6L) + bannerOptions.message = "Shutdown"; +#else + bannerOptions.message = "Shutdown Device?"; +#endif + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 2; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 1) { + InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_SHUTDOWN, .kbchar = 0, .touchX = 0, .touchY = 0}; + inputBroker->injectInputEvent(&event); + } else { + menuQueue = power_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::addFavoriteMenu() +{ +#if defined(M5STACK_UNITC6L) + screen->showNodePicker("Node Favorite", 30000, [](uint32_t nodenum) -> void { +#else + screen->showNodePicker("Node To Favorite", 30000, [](uint32_t nodenum) -> void { + +#endif + LOG_WARN("Nodenum: %u", nodenum); + nodeDB->set_favorite(true, nodenum); + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + }); +} + +void menuHandler::removeFavoriteMenu() +{ + + static const char *optionsArray[] = {"Back", "Yes"}; + BannerOverlayOptions bannerOptions; + std::string message = "Unfavorite This Node?\n"; + auto node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum); + if (node && node->has_user) { + message += sanitizeString(node->user.long_name).substr(0, 15); + } + bannerOptions.message = message.c_str(); + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 2; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 1) { + LOG_INFO("Removing %x as favorite node", graphics::UIRenderer::currentFavoriteNodeNum); + nodeDB->set_favorite(false, graphics::UIRenderer::currentFavoriteNodeNum); + screen->setFrames(graphics::Screen::FOCUS_DEFAULT); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::traceRouteMenu() +{ + screen->showNodePicker("Node to Trace", 30000, [](uint32_t nodenum) -> void { + LOG_INFO("Menu: Node picker selected node 0x%08x, traceRouteModule=%p", nodenum, traceRouteModule); + if (traceRouteModule) { + traceRouteModule->startTraceRoute(nodenum); + } + }); +} + +void menuHandler::testMenu() +{ + + enum optionsNumbers { Back, NumberPicker, ShowChirpy }; + static const char *optionsArray[4] = {"Back"}; + static int optionsEnumArray[4] = {Back}; + int options = 1; + + optionsArray[options] = "Number Picker"; + optionsEnumArray[options++] = NumberPicker; + + optionsArray[options] = screen->isFrameHidden("chirpy") ? "Show Chirpy" : "Hide Chirpy"; + optionsEnumArray[options++] = ShowChirpy; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Hidden Test Menu"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == NumberPicker) { + menuQueue = number_test; + screen->runNow(); + } else if (selected == ShowChirpy) { + screen->toggleFrameVisibility("chirpy"); + screen->setFrames(Screen::FOCUS_SYSTEM); + + } else { + menuQueue = system_base_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::numberTest() +{ + screen->showNumberPicker("Pick a number\n ", 30000, 4, + [](int number_picked) -> void { LOG_WARN("Nodenum: %u", number_picked); }); +} + +void menuHandler::wifiBaseMenu() +{ + enum optionsNumbers { Back, Wifi_toggle }; + + static const char *optionsArray[] = {"Back", "WiFi Toggle"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "WiFi Menu"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 2; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Wifi_toggle) { + menuQueue = wifi_toggle_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::wifiToggleMenu() +{ + enum optionsNumbers { Back, Wifi_disable, Wifi_enable }; + + static const char *optionsArray[] = {"Back", "WiFi Disabled", "WiFi Enabled"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "WiFi Actions"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + if (config.network.wifi_enabled == true) + bannerOptions.InitialSelected = 2; + else + bannerOptions.InitialSelected = 1; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Wifi_disable) { + config.network.wifi_enabled = false; + config.bluetooth.enabled = true; + service->reloadConfig(SEGMENT_CONFIG); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + } else if (selected == Wifi_enable) { + config.network.wifi_enabled = true; + config.bluetooth.enabled = false; + service->reloadConfig(SEGMENT_CONFIG); + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::notificationsMenu() +{ + enum optionsNumbers { Back, BuzzerActions }; + static const char *optionsArray[] = {"Back", "Buzzer Actions"}; + static int optionsEnumArray[] = {Back, BuzzerActions}; + int options = 2; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Notifications"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == BuzzerActions) { + menuHandler::menuQueue = menuHandler::buzzermodemenupicker; + screen->runNow(); + } else { + menuQueue = system_base_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::screenOptionsMenu() +{ + // Check if brightness is supported + bool hasSupportBrightness = false; +#if defined(ST7789_CS) || defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) + hasSupportBrightness = true; +#endif + +#if defined(T_DECK) + // TDeck Doesn't seem to support brightness at all, at least not reliably + hasSupportBrightness = false; +#endif + + enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits }; + static const char *optionsArray[5] = {"Back"}; + static int optionsEnumArray[5] = {Back}; + int options = 1; + + // Only show brightness for B&W displays + if (hasSupportBrightness) { + optionsArray[options] = "Brightness"; + optionsEnumArray[options++] = Brightness; + } + + // Only show screen color for TFT displays +#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \ + HAS_TFT || defined(HACKADAY_COMMUNICATOR) + optionsArray[options] = "Screen Color"; + optionsEnumArray[options++] = ScreenColor; +#endif + + optionsArray[options] = "Frame Visibility Toggle"; + optionsEnumArray[options++] = FrameToggles; + + optionsArray[options] = "Display Units"; + optionsEnumArray[options++] = DisplayUnits; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Display Options"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Brightness) { + menuHandler::menuQueue = menuHandler::brightness_picker; + screen->runNow(); + } else if (selected == ScreenColor) { + menuHandler::menuQueue = menuHandler::tftcolormenupicker; + screen->runNow(); + } else if (selected == FrameToggles) { + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == DisplayUnits) { + menuHandler::menuQueue = menuHandler::DisplayUnits; + screen->runNow(); + } else { + menuQueue = system_base_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::powerMenu() +{ + + enum optionsNumbers { Back, Reboot, Shutdown, MUI }; + static const char *optionsArray[4] = {"Back"}; + static int optionsEnumArray[4] = {Back}; + int options = 1; + + optionsArray[options] = "Reboot"; + optionsEnumArray[options++] = Reboot; + + optionsArray[options] = "Shutdown"; + optionsEnumArray[options++] = Shutdown; + +#if HAS_TFT + optionsArray[options] = "Switch to MUI"; + optionsEnumArray[options++] = MUI; +#endif + + BannerOverlayOptions bannerOptions; +#if defined(M5STACK_UNITC6L) + bannerOptions.message = "Power"; +#else + bannerOptions.message = "Reboot / Shutdown"; +#endif + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Reboot) { + menuHandler::menuQueue = menuHandler::reboot_menu; + screen->runNow(); + } else if (selected == Shutdown) { + menuHandler::menuQueue = menuHandler::shutdown_menu; + screen->runNow(); + } else if (selected == MUI) { + menuHandler::menuQueue = menuHandler::mui_picker; + screen->runNow(); + } else { + menuQueue = system_base_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::keyVerificationInitMenu() +{ + screen->showNodePicker("Node to Verify", 30000, + [](uint32_t selected) -> void { keyVerificationModule->sendInitialRequest(selected); }); +} + +void menuHandler::keyVerificationFinalPrompt() +{ + char message[40] = {0}; + memset(message, 0, sizeof(message)); + sprintf(message, "Verification: \n"); + keyVerificationModule->generateVerificationCode(message + 15); // send the toPhone packet + + if (screen) { + static const char *optionsArray[] = {"Reject", "Accept"}; + graphics::BannerOverlayOptions options; + options.message = message; + options.durationMs = 30000; + options.optionsArrayPtr = optionsArray; + options.optionsCount = 2; + options.notificationType = graphics::notificationTypeEnum::selection_picker; + options.bannerCallback = [=](int selected) { + if (selected == 1) { + auto remoteNodePtr = nodeDB->getMeshNode(keyVerificationModule->getCurrentRemoteNode()); + remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + } + }; + screen->showOverlayBanner(options); + } +} + +void menuHandler::FrameToggles_menu() +{ + enum optionsNumbers { + Finish, + nodelist_nodes, + nodelist_location, + nodelist_lastheard, + nodelist_hopsignal, + nodelist_distance, + nodelist_bearings, + gps, + lora, + clock, + show_favorites, + show_telemetry, + show_power, + enumEnd + }; + static const char *optionsArray[enumEnd] = {"Finish"}; + static int optionsEnumArray[enumEnd] = {Finish}; + int options = 1; + + // Track last selected index (not enum value!) + static int lastSelectedIndex = 0; + +#ifndef USE_EINK + optionsArray[options] = screen->isFrameHidden("nodelist_nodes") ? "Show Node List" : "Hide Node List"; + optionsEnumArray[options++] = nodelist_nodes; +#else + optionsArray[options] = screen->isFrameHidden("nodelist_lastheard") ? "Show NL - Last Heard" : "Hide NL - Last Heard"; + optionsEnumArray[options++] = nodelist_lastheard; + optionsArray[options] = screen->isFrameHidden("nodelist_hopsignal") ? "Show NL - Hops/Signal" : "Hide NL - Hops/Signal"; + optionsEnumArray[options++] = nodelist_hopsignal; +#endif + +#if HAS_GPS +#ifndef USE_EINK + optionsArray[options] = screen->isFrameHidden("nodelist_location") ? "Show Node Location List" : "Hide Node Location List"; + optionsEnumArray[options++] = nodelist_location; +#else + optionsArray[options] = screen->isFrameHidden("nodelist_distance") ? "Show NL - Distance" : "Hide NL - Distance"; + optionsEnumArray[options++] = nodelist_distance; + optionsArray[options] = screen->isFrameHidden("nodelist_bearings") ? "Show NL - Bearings" : "Hide NL - Bearings"; + optionsEnumArray[options++] = nodelist_bearings; +#endif + + optionsArray[options] = screen->isFrameHidden("gps") ? "Show Position" : "Hide Position"; + optionsEnumArray[options++] = gps; +#endif + + optionsArray[options] = screen->isFrameHidden("lora") ? "Show LoRa" : "Hide LoRa"; + optionsEnumArray[options++] = lora; + + optionsArray[options] = screen->isFrameHidden("clock") ? "Show Clock" : "Hide Clock"; + optionsEnumArray[options++] = clock; + + optionsArray[options] = screen->isFrameHidden("show_favorites") ? "Show Favorites" : "Hide Favorites"; + optionsEnumArray[options++] = show_favorites; + + optionsArray[options] = moduleConfig.telemetry.environment_screen_enabled ? "Hide Telemetry" : "Show Telemetry"; + optionsEnumArray[options++] = show_telemetry; + + optionsArray[options] = moduleConfig.telemetry.power_screen_enabled ? "Hide Power" : "Show Power"; + optionsEnumArray[options++] = show_power; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Show/Hide Frames"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.InitialSelected = lastSelectedIndex; // Use index, not enum value + + bannerOptions.bannerCallback = [options](int selected) mutable -> void { + // Find the index of selected in optionsEnumArray + int idx = 0; + for (; idx < options; ++idx) { + if (optionsEnumArray[idx] == selected) + break; + } + lastSelectedIndex = idx; + + if (selected == Finish) { + screen->setFrames(Screen::FOCUS_DEFAULT); + } else if (selected == nodelist_nodes) { + screen->toggleFrameVisibility("nodelist_nodes"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_location) { + screen->toggleFrameVisibility("nodelist_location"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_lastheard) { + screen->toggleFrameVisibility("nodelist_lastheard"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_hopsignal) { + screen->toggleFrameVisibility("nodelist_hopsignal"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_distance) { + screen->toggleFrameVisibility("nodelist_distance"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_bearings) { + screen->toggleFrameVisibility("nodelist_bearings"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == gps) { + screen->toggleFrameVisibility("gps"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == lora) { + screen->toggleFrameVisibility("lora"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == clock) { + screen->toggleFrameVisibility("clock"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == show_favorites) { + screen->toggleFrameVisibility("show_favorites"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == show_telemetry) { + moduleConfig.telemetry.environment_screen_enabled = !moduleConfig.telemetry.environment_screen_enabled; + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == show_power) { + moduleConfig.telemetry.power_screen_enabled = !moduleConfig.telemetry.power_screen_enabled; + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::DisplayUnits_menu() +{ + enum optionsNumbers { Back, MetricUnits, ImperialUnits }; + + static const char *optionsArray[] = {"Back", "Metric", "Imperial"}; + BannerOverlayOptions bannerOptions; + bannerOptions.message = " Select display units"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) + bannerOptions.InitialSelected = 2; + else + bannerOptions.InitialSelected = 1; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == MetricUnits) { + config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC; + service->reloadConfig(SEGMENT_CONFIG); + } else if (selected == ImperialUnits) { + config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL; + service->reloadConfig(SEGMENT_CONFIG); + } else { + menuHandler::menuQueue = menuHandler::screen_options_menu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::handleMenuSwitch(OLEDDisplay *display) +{ + if (menuQueue != menu_none) + test_count = 0; + switch (menuQueue) { + case menu_none: + break; + case lora_Menu: + loraMenu(); + break; + case lora_picker: + LoraRegionPicker(); + break; + case device_role_picker: + DeviceRolePicker(); + break; + case radio_preset_picker: + RadioPresetPicker(); + break; + case no_timeout_lora_picker: + LoraRegionPicker(0); + break; + case TZ_picker: + TZPicker(); + break; + case twelve_hour_picker: + TwelveHourPicker(); + break; + case clock_face_picker: + ClockFacePicker(); + break; + case clock_menu: + clockMenu(); + break; + case system_base_menu: + systemBaseMenu(); + break; + case position_base_menu: + positionBaseMenu(); + break; +#if !MESHTASTIC_EXCLUDE_GPS + case gps_toggle_menu: + GPSToggleMenu(); + break; + case gps_format_menu: + GPSFormatMenu(); + break; +#endif + case compass_point_north_menu: + compassNorthMenu(); + break; + case reset_node_db_menu: + resetNodeDBMenu(); + break; + case buzzermodemenupicker: + BuzzerModeMenu(); + break; + case mui_picker: + switchToMUIMenu(); + break; + case tftcolormenupicker: + TFTColorPickerMenu(display); + break; + case brightness_picker: + BrightnessPickerMenu(); + break; + case node_name_length_menu: + nodeNameLengthMenu(); + break; + case reboot_menu: + rebootMenu(); + break; + case shutdown_menu: + shutdownMenu(); + break; + case add_favorite: + addFavoriteMenu(); + break; + case remove_favorite: + removeFavoriteMenu(); + break; + case trace_route_menu: + traceRouteMenu(); + break; + case test_menu: + testMenu(); + break; + case number_test: + numberTest(); + break; + case wifi_toggle_menu: + wifiToggleMenu(); + break; + case key_verification_init: + keyVerificationInitMenu(); + break; + case key_verification_final_prompt: + keyVerificationFinalPrompt(); + break; + case bluetooth_toggle_menu: + BluetoothToggleMenu(); + break; + case notifications_menu: + notificationsMenu(); + break; + case screen_options_menu: + screenOptionsMenu(); + break; + case power_menu: + powerMenu(); + break; + case FrameToggles: + FrameToggles_menu(); + break; + case DisplayUnits: + DisplayUnits_menu(); + break; + case throttle_message: + screen->showSimpleBanner("Too Many Attempts\nTry again in 60 seconds.", 5000); + break; + case message_response_menu: + messageResponseMenu(); + break; + case reply_menu: + replyMenu(); + break; + case delete_messages_menu: + deleteMessagesMenu(); + break; + case message_viewmode_menu: + messageViewModeMenu(); + break; + } + menuQueue = menu_none; +} + +void menuHandler::saveUIConfig() +{ + nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uiconfig); +} + +} // namespace graphics + +#endif \ No newline at end of file diff --git a/NodeListRenderer.cpp b/NodeListRenderer.cpp new file mode 100644 index 00000000000..a1020268e07 --- /dev/null +++ b/NodeListRenderer.cpp @@ -0,0 +1,698 @@ +#include "configuration.h" +#if HAS_SCREEN +#include "CompassRenderer.h" +#include "NodeDB.h" +#include "NodeListRenderer.h" +#include "UIRenderer.h" +#include "gps/GeoCoord.h" +#include "gps/RTC.h" // for getTime() function +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" +#include "meshUtils.h" +#include + +// Forward declarations for functions defined in Screen.cpp +namespace graphics +{ +extern bool haveGlyphs(const char *str); +} // namespace graphics + +// Global screen instance +extern graphics::Screen *screen; + +#if defined(M5STACK_UNITC6L) +static uint32_t lastSwitchTime = 0; +#else +#endif +namespace graphics +{ +namespace NodeListRenderer +{ + +// Function moved from Screen.cpp to NodeListRenderer.cpp since it's primarily used here +void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display) +{ + for (int row = 0; row < height; row++) { + uint8_t rowMask = (1 << row); + for (int col = 0; col < width; col++) { + uint8_t colData = pgm_read_byte(&bitmapXBM[col]); + if (colData & rowMask) { + // Note: rows become X, columns become Y after transpose + display->fillRect(x + row * 2, y + col * 2, 2, 2); + } + } + } +} + +// Static variables for dynamic cycling +static ListMode_Node currentMode_Nodes = MODE_LAST_HEARD; +static ListMode_Location currentMode_Location = MODE_DISTANCE; +static int scrollIndex = 0; + +// ============================= +// Utility Functions +// ============================= + +const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node) +{ + static char nodeName[25]; // single static buffer we return + nodeName[0] = '\0'; + + auto writeFallbackId = [&] { + std::snprintf(nodeName, sizeof(nodeName), "(%04X)", static_cast(node ? (node->num & 0xFFFF) : 0)); + }; + + // 1) Choose target candidate (long vs short) only if present + const char *raw = nullptr; + if (node && node->has_user) { + raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name; + } + + // 2) Sanitize (empty if raw is null/empty) + std::string s = (raw && *raw) ? sanitizeString(raw) : std::string{}; + + // 3) Fallback if sanitize yields empty; otherwise copy safely (truncate if needed) + if (s.empty() || s == "¿" || s.find_first_not_of("¿") == std::string::npos) { + writeFallbackId(); + } else { + // %.*s ensures null-termination and safe truncation to buffer size - 1 + std::snprintf(nodeName, sizeof(nodeName), "%.*s", static_cast(sizeof(nodeName) - 1), s.c_str()); + } + + // 4) Width-based truncation + ellipsis (long-name mode only) + if (config.display.use_long_node_name && display) { + int availWidth = (SCREEN_WIDTH / 2) - 65; + if (availWidth < 0) + availWidth = 0; + + const size_t beforeLen = std::strlen(nodeName); + + // Trim from the end until it fits or is empty + size_t len = beforeLen; + while (len && display->getStringWidth(nodeName) > availWidth) { + nodeName[--len] = '\0'; + } + + // If truncated, append "..." (respect buffer size) + if (len < beforeLen) { + // Make sure there's room for "..." and '\0' + const size_t capForText = sizeof(nodeName) - 1; // leaving space for '\0' + const size_t needed = 3; // "..." + if (len > capForText - needed) { + len = capForText - needed; + nodeName[len] = '\0'; + } + std::strcat(nodeName, "..."); + } + } + + return nodeName; +} + +const char *getCurrentModeTitle_Nodes(int screenWidth) +{ + switch (currentMode_Nodes) { + case MODE_LAST_HEARD: + return "Last Heard"; + case MODE_HOP_SIGNAL: +#ifdef USE_EINK + return "Hops/Sig"; +#else + return (isHighResolution) ? "Hops/Signal" : "Hops/Sig"; +#endif + default: + return "Nodes"; + } +} + +const char *getCurrentModeTitle_Location(int screenWidth) +{ + switch (currentMode_Location) { + case MODE_DISTANCE: + return "Distance"; + case MODE_BEARING: + return "Bearings"; + default: + return "Nodes"; + } +} + +// Use dynamic timing based on mode +unsigned long getModeCycleIntervalMs() +{ + return 3000; +} + +int calculateMaxScroll(int totalEntries, int visibleRows) +{ + return std::max(0, (totalEntries - 1) / (visibleRows * 2)); +} + +void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd) +{ + for (int y = yStart; y <= yEnd; y += 2) { + display->setPixel(x, y); + } +} + +void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, int scrollIndex, int columns, int scrollStartY) +{ + if (totalEntries <= visibleNodeRows * columns) + return; + + int scrollbarX = display->getWidth() - 2; + int scrollbarHeight = display->getHeight() - scrollStartY - 10; + int thumbHeight = std::max(4, (scrollbarHeight * visibleNodeRows * columns) / totalEntries); + int maxScroll = calculateMaxScroll(totalEntries, visibleNodeRows); + int thumbY = scrollStartY + (scrollIndex * (scrollbarHeight - thumbHeight)) / std::max(1, maxScroll); + + for (int i = 0; i < thumbHeight; i++) { + display->setPixel(scrollbarX, thumbY + i); + } +} + +// ============================= +// Entry Renderers +// ============================= + +void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + bool isLeftCol = (x < SCREEN_WIDTH / 2); + int timeOffset = (isHighResolution) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7); + + const char *nodeName = getSafeNodeName(display, node); + + char timeStr[10]; + uint32_t seconds = sinceLastSeen(node); + if (seconds == 0 || seconds == UINT32_MAX) { + snprintf(timeStr, sizeof(timeStr), "?"); + } else { + uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24; + snprintf(timeStr, sizeof(timeStr), (days > 365 ? "?" : "%d%c"), + (days ? days + : hours ? hours + : minutes), + (days ? 'd' + : hours ? 'h' + : 'm')); + } + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawString(x + ((isHighResolution) ? 6 : 3), y, nodeName); + if (node->is_favorite) { + if (isHighResolution) { + drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); + } else { + display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); + } + } + + int rightEdge = x + columnWidth - timeOffset; + if (timeStr[strlen(timeStr) - 1] == 'm') // Fix the fact that our fonts don't line up well all the time + rightEdge -= 1; + int textWidth = display->getStringWidth(timeStr); + display->drawString(rightEdge - textWidth, y, timeStr); +} + +void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + bool isLeftCol = (x < SCREEN_WIDTH / 2); + + int nameMaxWidth = columnWidth - 25; + int barsOffset = (isHighResolution) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19); + int hopOffset = (isHighResolution) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17); + + int barsXOffset = columnWidth - barsOffset; + + const char *nodeName = getSafeNodeName(display, node); + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + display->drawStringMaxWidth(x + ((isHighResolution) ? 6 : 3), y, nameMaxWidth, nodeName); + if (node->is_favorite) { + if (isHighResolution) { + drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); + } else { + display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); + } + } + + // Draw signal strength bars + int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0; + int barWidth = 2; + int barStartX = x + barsXOffset; + int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2; + + for (int b = 0; b < 4; b++) { + if (b < bars) { + int height = (b * 2); + display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height); + } + } + + // Draw hop count + char hopStr[6] = ""; + if (node->has_hops_away && node->hops_away > 0) + snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away); + + if (hopStr[0] != '\0') { + int rightEdge = x + columnWidth - hopOffset; + int textWidth = display->getStringWidth(hopStr); + display->drawString(rightEdge - textWidth, y, hopStr); + } +} + +void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + bool isLeftCol = (x < SCREEN_WIDTH / 2); + int nameMaxWidth = columnWidth - (isHighResolution ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); + + const char *nodeName = getSafeNodeName(display, node); + char distStr[10] = ""; + + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) { + double lat1 = ourNode->position.latitude_i * 1e-7; + double lon1 = ourNode->position.longitude_i * 1e-7; + double lat2 = node->position.latitude_i * 1e-7; + double lon2 = node->position.longitude_i * 1e-7; + + double earthRadiusKm = 6371.0; + double dLat = (lat2 - lat1) * DEG_TO_RAD; + double dLon = (lon2 - lon1) * DEG_TO_RAD; + + double a = + sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2); + double c = 2 * atan2(sqrt(a), sqrt(1 - a)); + double distanceKm = earthRadiusKm * c; + + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { + double miles = distanceKm * 0.621371; + if (miles < 0.1) { + int feet = (int)(miles * 5280); + if (feet < 1000) + snprintf(distStr, sizeof(distStr), "%dft", feet); + else + snprintf(distStr, sizeof(distStr), "¼mi"); // 4-char max + } else { + int roundedMiles = (int)(miles + 0.5); + if (roundedMiles < 1000) + snprintf(distStr, sizeof(distStr), "%dmi", roundedMiles); + else + snprintf(distStr, sizeof(distStr), "999"); // Max display cap + } + } else { + if (distanceKm < 1.0) { + int meters = (int)(distanceKm * 1000); + if (meters < 1000) + snprintf(distStr, sizeof(distStr), "%dm", meters); + else + snprintf(distStr, sizeof(distStr), "1k"); + } else { + int km = (int)(distanceKm + 0.5); + if (km < 1000) + snprintf(distStr, sizeof(distStr), "%dk", km); + else + snprintf(distStr, sizeof(distStr), "999"); + } + } + } + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawStringMaxWidth(x + ((isHighResolution) ? 6 : 3), y, nameMaxWidth, nodeName); + if (node->is_favorite) { + if (isHighResolution) { + drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); + } else { + display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); + } + } + + if (strlen(distStr) > 0) { + int offset = (isHighResolution) ? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column) + : (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column) + int rightEdge = x + columnWidth - offset; + int textWidth = display->getStringWidth(distStr); + display->drawString(rightEdge - textWidth, y, distStr); + } +} + +void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + switch (currentMode_Nodes) { + case MODE_LAST_HEARD: + drawEntryLastHeard(display, node, x, y, columnWidth); + break; + case MODE_HOP_SIGNAL: + drawEntryHopSignal(display, node, x, y, columnWidth); + break; + default: + break; + } +} + +void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +{ + bool isLeftCol = (x < SCREEN_WIDTH / 2); + + // Adjust max text width depending on column and screen width + int nameMaxWidth = columnWidth - (isHighResolution ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); + + const char *nodeName = getSafeNodeName(display, node); + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawStringMaxWidth(x + ((isHighResolution) ? 6 : 3), y, nameMaxWidth, nodeName); + if (node->is_favorite) { + if (isHighResolution) { + drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); + } else { + display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); + } + } +} + +void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading, + double userLat, double userLon) +{ + if (!nodeDB->hasValidPosition(node)) + return; + + bool isLeftCol = (x < SCREEN_WIDTH / 2); + int arrowXOffset = (isHighResolution) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18); + + int centerX = x + columnWidth - arrowXOffset; + int centerY = y + FONT_HEIGHT_SMALL / 2; + + double nodeLat = node->position.latitude_i * 1e-7; + double nodeLon = node->position.longitude_i * 1e-7; + float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon); + float bearingToNode = RAD_TO_DEG * bearing; + float relativeBearing = fmod((bearingToNode - myHeading + 360), 360); + // Shrink size by 2px + int size = FONT_HEIGHT_SMALL - 5; + CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearing); + /* + float angle = relativeBearing * DEG_TO_RAD; + float halfSize = size / 2.0; + + // Point of the arrow + int tipX = centerX + halfSize * cos(angle); + int tipY = centerY - halfSize * sin(angle); + + float baseAngle = radians(35); + float sideLen = halfSize * 0.95; + float notchInset = halfSize * 0.35; + + // Left and right corners + int leftX = centerX + sideLen * cos(angle + PI - baseAngle); + int leftY = centerY - sideLen * sin(angle + PI - baseAngle); + + int rightX = centerX + sideLen * cos(angle + PI + baseAngle); + int rightY = centerY - sideLen * sin(angle + PI + baseAngle); + + // Center notch (cut-in) + int notchX = centerX - notchInset * cos(angle); + int notchY = centerY + notchInset * sin(angle); + + // Draw the chevron-style arrowhead + display->fillTriangle(tipX, tipY, leftX, leftY, notchX, notchY); + display->fillTriangle(tipX, tipY, notchX, notchY, rightX, rightY); + */ +} + +// ============================= +// Main Screen Functions +// ============================= + +void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, + EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon) +{ + const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1; + const int rowYOffset = FONT_HEIGHT_SMALL - 3; + bool locationScreen = false; + + if (strcmp(title, "Bearings") == 0) + locationScreen = true; + else if (strcmp(title, "Distance") == 0) + locationScreen = true; +#if defined(M5STACK_UNITC6L) + int columnWidth = display->getWidth(); +#elif defined(T_LORA_PAGER) + int columnWidth = display->getWidth() / 3; + if (config.display.use_long_node_name) { + columnWidth = display->getWidth() / 2; + } +#else + int columnWidth = display->getWidth() / 2; +#endif + + display->clear(); + + // Draw the battery/time header + graphics::drawCommonHeader(display, x, y, title); + + // Space below header + y += COMMON_HEADER_HEIGHT; + + int totalEntries = nodeDB->getNumMeshNodes(); + int totalRowsAvailable = (display->getHeight() - y) / rowYOffset; + int numskipped = 0; + int visibleNodeRows = totalRowsAvailable; +#if defined(M5STACK_UNITC6L) + int totalColumns = 1; +#elif defined(T_LORA_PAGER) + int totalColumns = 3; + if (config.display.use_long_node_name) { + totalColumns = 2; + } +#else + int totalColumns = 2; +#endif + int startIndex = scrollIndex * visibleNodeRows * totalColumns; + if (nodeDB->getMeshNodeByIndex(startIndex)->num == nodeDB->getNodeNum()) { + startIndex++; // skip own node + } + int endIndex = std::min(startIndex + visibleNodeRows * totalColumns, totalEntries); + + int yOffset = 0; + int col = 0; + int lastNodeY = y; + int shownCount = 0; + int rowCount = 0; + + for (int i = startIndex; i < endIndex; ++i) { + if (locationScreen && !nodeDB->getMeshNodeByIndex(i)->has_position) { + numskipped++; + continue; + } + int xPos = x + (col * columnWidth); + int yPos = y + yOffset; + renderer(display, nodeDB->getMeshNodeByIndex(i), xPos, yPos, columnWidth); + + if (extras) { + extras(display, nodeDB->getMeshNodeByIndex(i), xPos, yPos, columnWidth, heading, lat, lon); + } + + lastNodeY = std::max(lastNodeY, yPos + FONT_HEIGHT_SMALL); + yOffset += rowYOffset; + shownCount++; + rowCount++; + + if (rowCount >= totalRowsAvailable) { + yOffset = 0; + rowCount = 0; + col++; + if (col > (totalColumns - 1)) + break; + } + } + + // This should correct the scrollbar + totalEntries -= numskipped; + +#if !defined(M5STACK_UNITC6L) + // Draw column separator + if (shownCount > 0) { + const int firstNodeY = y + 3; + for (int horizontal_offset = 1; horizontal_offset < totalColumns; horizontal_offset++) { + drawColumnSeparator(display, columnWidth * horizontal_offset, firstNodeY, lastNodeY); + } + } + +#endif + const int scrollStartY = y + 3; + drawScrollbar(display, visibleNodeRows, totalEntries, scrollIndex, 2, scrollStartY); + graphics::drawCommonFooter(display, x, y); +} + +// ============================= +// Screen Frame Functions +// ============================= + +#ifndef USE_EINK +// Node list for Last Heard and Hop Signal views +void drawDynamicListScreen_Nodes(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + // Static variables to track mode and duration + static ListMode_Node lastRenderedMode = MODE_COUNT_NODE; + static unsigned long modeStartTime = 0; + + unsigned long now = millis(); + +#if defined(M5STACK_UNITC6L) + display->clear(); + if (now - lastSwitchTime >= 3000) { + display->display(); + lastSwitchTime = now; + } +#endif + // On very first call (on boot or state enter) + if (lastRenderedMode == MODE_COUNT_NODE) { + currentMode_Nodes = MODE_LAST_HEARD; + modeStartTime = now; + } + + // Time to switch to next mode? + if (now - modeStartTime >= getModeCycleIntervalMs()) { + currentMode_Nodes = static_cast((currentMode_Nodes + 1) % MODE_COUNT_NODE); + modeStartTime = now; + } + + // Render screen based on currentMode + const char *title = getCurrentModeTitle_Nodes(display->getWidth()); + drawNodeListScreen(display, state, x, y, title, drawEntryDynamic_Nodes); + + // Track the last mode to avoid reinitializing modeStartTime + lastRenderedMode = currentMode_Nodes; +} + +// Node list for Distance and Bearings views +void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + // Static variables to track mode and duration + static ListMode_Location lastRenderedMode = MODE_COUNT_LOCATION; + static unsigned long modeStartTime = 0; + + unsigned long now = millis(); + +#if defined(M5STACK_UNITC6L) + display->clear(); + if (now - lastSwitchTime >= 3000) { + display->display(); + lastSwitchTime = now; + } +#endif + // On very first call (on boot or state enter) + if (lastRenderedMode == MODE_COUNT_LOCATION) { + currentMode_Location = MODE_DISTANCE; + modeStartTime = now; + } + + // Time to switch to next mode? + if (now - modeStartTime >= getModeCycleIntervalMs()) { + currentMode_Location = static_cast((currentMode_Location + 1) % MODE_COUNT_LOCATION); + modeStartTime = now; + } + + // Render screen based on currentMode + const char *title = getCurrentModeTitle_Location(display->getWidth()); + + // Render screen based on currentMode_Location + if (currentMode_Location == MODE_DISTANCE) { + drawNodeListScreen(display, state, x, y, title, drawNodeDistance); + } else if (currentMode_Location == MODE_BEARING) { + drawNodeListWithCompasses(display, state, x, y); + } + + // Track the last mode to avoid reinitializing modeStartTime + lastRenderedMode = currentMode_Location; +} +#endif + +#ifdef USE_EINK +void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + const char *title = "Last Heard"; + drawNodeListScreen(display, state, x, y, title, drawEntryLastHeard); +} + +void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ +#ifdef USE_EINK + const char *title = "Hops/Sig"; +#else + + const char *title = "Hops/Signal"; +#endif + drawNodeListScreen(display, state, x, y, title, drawEntryHopSignal); +} +void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + const char *title = "Distance"; + drawNodeListScreen(display, state, x, y, title, drawNodeDistance); +} +#endif +void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + float heading = 0; + bool validHeading = false; + auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + double lat = DegD(ourNode->position.latitude_i); + double lon = DegD(ourNode->position.longitude_i); + +#if defined(M5STACK_UNITC6L) + display->clear(); + uint32_t now = millis(); + if (now - lastSwitchTime >= 2000) { + display->display(); + lastSwitchTime = now; + } +#endif + if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) { +#if HAS_GPS + if (screen->hasHeading()) { + heading = screen->getHeading(); // degrees + validHeading = true; + } else { + heading = screen->estimatedHeading(lat, lon); + validHeading = !isnan(heading); + } +#endif + + if (!validHeading) + return; + } + drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon); +} + +/// Draw a series of fields in a column, wrapping to multiple columns if needed +void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields) +{ + // The coordinates define the left starting point of the text + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const char **f = fields; + int xo = x, yo = y; + while (*f) { + display->drawString(xo, yo, *f); + if ((display->getColor() == BLACK) && config.display.heading_bold) + display->drawString(xo + 1, yo, *f); + + display->setColor(WHITE); + yo += FONT_HEIGHT_SMALL; + if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) { + xo += SCREEN_WIDTH / 2; + yo = 0; + } + f++; + } +} + +} // namespace NodeListRenderer +} // namespace graphics +#endif \ No newline at end of file diff --git a/NodeListRenderer.h b/NodeListRenderer.h new file mode 100644 index 00000000000..8b82fb1177c --- /dev/null +++ b/NodeListRenderer.h @@ -0,0 +1,67 @@ +#pragma once + +#include "graphics/Screen.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +namespace graphics +{ + +/// Forward declarations +class Screen; + +/** + * @brief Node list and entry rendering functions + * + * Contains all functions related to drawing node lists and individual node entries + * including last heard, hop signal, distance, and compass views. + */ +namespace NodeListRenderer +{ +// Entry renderer function types +typedef void (*EntryRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int); +typedef void (*NodeExtrasRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int, float, double, double); + +// Node list mode enumeration for Last Heard and Hop Signal views +enum ListMode_Node { MODE_LAST_HEARD = 0, MODE_HOP_SIGNAL = 1, MODE_COUNT_NODE = 2 }; + +// Node list mode enumeration for Distance and Bearings views +enum ListMode_Location { MODE_DISTANCE = 0, MODE_BEARING = 1, MODE_COUNT_LOCATION = 2 }; + +// Main node list screen function +void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, + EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0, + double lon = 0); + +// Entry renderers +void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); +void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); +void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); +void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); +void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); + +// Extras renderers +void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading, + double userLat, double userLon); + +// Screen frame functions +void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDynamicListScreen_Nodes(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// Utility functions +const char *getCurrentModeTitle_Nodes(int screenWidth); +const char *getCurrentModeTitle_Location(int screenWidth); +const char *getSafeNodeName(meshtastic_NodeInfoLite *node); +void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields); + +// Bitmap drawing function +void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display); + +} // namespace NodeListRenderer + +} // namespace graphics diff --git a/Screen.cpp b/Screen.cpp new file mode 100644 index 00000000000..149dd9eacf8 --- /dev/null +++ b/Screen.cpp @@ -0,0 +1,1804 @@ +/* +BaseUI + +Developed and Maintained By: +- Ronald Garcia (HarukiToreda) – Lead development and implementation. +- JasonP (Xaositek) – Screen layout and icon design, UI improvements and testing. +- TonyG (Tropho) – Project management, structural planning, and testing + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +*/ +#include "Screen.h" +#include "NodeDB.h" +#include "PowerMon.h" +#include "Throttle.h" +#include "configuration.h" +#include "meshUtils.h" +#if HAS_SCREEN +#include + +#include "DisplayFormatters.h" +#include "TimeFormatters.h" +#include "draw/ClockRenderer.h" +#include "draw/DebugRenderer.h" +#include "draw/MenuHandler.h" +#include "draw/MessageRenderer.h" +#include "draw/NodeListRenderer.h" +#include "draw/NotificationRenderer.h" +#include "draw/UIRenderer.h" +#include "modules/CannedMessageModule.h" + +#if !MESHTASTIC_EXCLUDE_GPS +#include "GPS.h" +#include "buzz.h" +#endif +#include "FSCommon.h" +#include "MeshService.h" +#include "MessageStore.h" +#include "RadioLibInterface.h" +#include "error.h" +#include "gps/GeoCoord.h" +#include "gps/RTC.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/emotes.h" +#include "graphics/images.h" +#include "input/TouchScreenImpl1.h" +#include "main.h" +#include "mesh-pb-constants.h" +#include "mesh/Channels.h" +#include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "modules/ExternalNotificationModule.h" +#include "modules/TextMessageModule.h" +#include "modules/WaypointModule.h" +#include "sleep.h" +#include "target_specific.h" +extern MessageStore messageStore; + +#if USE_TFTDISPLAY +extern uint16_t TFT_MESH; +#else +uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94); +#endif + +#if HAS_WIFI && !defined(ARCH_PORTDUINO) +#include "mesh/wifi/WiFiAPClient.h" +#endif + +#ifdef ARCH_ESP32 +#endif + +#if ARCH_PORTDUINO +#include "modules/StoreForwardModule.h" +#include "platform/portduino/PortduinoGlue.h" +#endif + +#if defined(T_LORA_PAGER) +// KB backlight control +#include "input/cardKbI2cImpl.h" +#endif + +using namespace meshtastic; /** @todo remove */ + +namespace graphics +{ + +// This means the *visible* area (sh1106 can address 132, but shows 128 for example) +#define IDLE_FRAMERATE 1 // in fps + +// DEBUG +#define NUM_EXTRA_FRAMES 3 // text message and debug frame +// if defined a pixel will blink to show redraws +// #define SHOW_REDRAWS +#define ASCII_BELL '\x07' +// A text message frame + debug frame + all the node infos +FrameCallback *normalFrames; +static uint32_t targetFramerate = IDLE_FRAMERATE; +// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization + +uint32_t logo_timeout = 5000; // 4 seconds for EACH logo + +// Threshold values for the GPS lock accuracy bar display +uint32_t dopThresholds[5] = {2000, 1000, 500, 200, 100}; + +// At some point, we're going to ask all of the modules if they would like to display a screen frame +// we'll need to hold onto pointers for the modules that can draw a frame. +std::vector moduleFrames; + +#if HAS_GPS +// GeoCoord object for the screen +GeoCoord geoCoord; +#endif + +#ifdef SHOW_REDRAWS +static bool heartbeat = false; +#endif + +#include "graphics/ScreenFonts.h" +#include + +// Usage: int stringWidth = formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display); +// End Functions to write date/time to the screen + +extern bool hasUnreadMessage; + +// ============================== +// Overlay Alert Banner Renderer +// ============================== +// Displays a temporary centered banner message (e.g., warning, status, etc.) +// The banner appears in the center of the screen and disappears after the specified duration + +void Screen::showSimpleBanner(const char *message, uint32_t durationMs) +{ + BannerOverlayOptions options; + options.message = message; + options.durationMs = durationMs; + options.notificationType = notificationTypeEnum::text_banner; + showOverlayBanner(options); +} + +// Called to trigger a banner with custom message and duration +void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) +{ +#ifdef USE_EINK + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus +#endif + // Store the message and set the expiration timestamp + strncpy(NotificationRenderer::alertBannerMessage, banner_overlay_options.message, 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination + NotificationRenderer::alertBannerUntil = + (banner_overlay_options.durationMs == 0) ? 0 : millis() + banner_overlay_options.durationMs; + NotificationRenderer::optionsArrayPtr = banner_overlay_options.optionsArrayPtr; + NotificationRenderer::optionsEnumPtr = banner_overlay_options.optionsEnumPtr; + NotificationRenderer::alertBannerOptions = banner_overlay_options.optionsCount; + NotificationRenderer::alertBannerCallback = banner_overlay_options.bannerCallback; + NotificationRenderer::curSelected = banner_overlay_options.InitialSelected; + NotificationRenderer::pauseBanner = false; + NotificationRenderer::current_notification_type = notificationTypeEnum::selection_picker; + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setTargetFPS(60); + ui->update(); +} + +// Called to trigger a banner with custom message and duration +void Screen::showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback) +{ +#ifdef USE_EINK + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus +#endif + nodeDB->pause_sort(true); + // Store the message and set the expiration timestamp + strncpy(NotificationRenderer::alertBannerMessage, message, 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerCallback = bannerCallback; + NotificationRenderer::pauseBanner = false; + NotificationRenderer::curSelected = 0; + NotificationRenderer::current_notification_type = notificationTypeEnum::node_picker; + + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setTargetFPS(60); + ui->update(); +} + +// Called to trigger a banner with custom message and duration +void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, + std::function bannerCallback) +{ +#ifdef USE_EINK + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus +#endif + // Store the message and set the expiration timestamp + strncpy(NotificationRenderer::alertBannerMessage, message, 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerCallback = bannerCallback; + NotificationRenderer::pauseBanner = false; + NotificationRenderer::curSelected = 0; + NotificationRenderer::current_notification_type = notificationTypeEnum::number_picker; + NotificationRenderer::numDigits = digits; + NotificationRenderer::currentNumber = 0; + + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setTargetFPS(60); + ui->update(); +} + +void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, + std::function textCallback) +{ + LOG_INFO("showTextInput called with header='%s', durationMs=%d", header ? header : "NULL", durationMs); + + // Start OnScreenKeyboardModule session (non-touch variant) + OnScreenKeyboardModule::instance().start(header, initialText, durationMs, textCallback); + NotificationRenderer::textInputCallback = textCallback; + + // Store the message and set the expiration timestamp (use same pattern as other notifications) + strncpy(NotificationRenderer::alertBannerMessage, header ? header : "Text Input", 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::pauseBanner = false; + NotificationRenderer::current_notification_type = notificationTypeEnum::text_input; + + // Set the overlay using the same pattern as other notification types + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setTargetFPS(60); + ui->update(); +} + +static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + uint8_t module_frame; + // there's a little but in the UI transition code + // where it invokes the function at the correct offset + // in the array of "drawScreen" functions; however, + // the passed-state doesn't quite reflect the "current" + // screen, so we have to detect it. + if (state->frameState == IN_TRANSITION && state->transitionFrameRelationship == TransitionRelationship_INCOMING) { + // if we're transitioning from the end of the frame list back around to the first + // frame, then we want this to be `0` + module_frame = state->transitionFrameTarget; + } else { + // otherwise, just display the module frame that's aligned with the current frame + module_frame = state->currentFrame; + } + MeshModule &pi = *moduleFrames.at(module_frame); + pi.drawFrame(display, state, x, y); +} + +/** + * Given a recent lat/lon return a guess of the heading the user is walking on. + * + * We keep a series of "after you've gone 10 meters, what is your heading since + * the last reference point?" + */ +float Screen::estimatedHeading(double lat, double lon) +{ + static double oldLat, oldLon; + static float b; + + if (oldLat == 0) { + // just prepare for next time + oldLat = lat; + oldLon = lon; + + return b; + } + + float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon); + if (d < 10) // haven't moved enough, just keep current bearing + return b; + + b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG; + oldLat = lat; + oldLon = lon; + + return b; +} + +/// We will skip one node - the one for us, so we just blindly loop over all +/// nodes +static int8_t prevFrame = -1; + +// Combined dynamic node list frame cycling through LastHeard, HopSignal, and Distance modes +// Uses a single frame and changes data every few seconds (E-Ink variant is separate) + +#if defined(ESP_PLATFORM) && (defined(USE_ST7789) || defined(USE_ST7796)) +SPIClass SPI1(HSPI); +#endif + +Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_OledType screenType, OLEDDISPLAY_GEOMETRY geometry) + : concurrency::OSThread("Screen"), address_found(address), model(screenType), geometry(geometry), cmdQueue(32) +{ + graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES]; + + int32_t rawRGB = uiconfig.screen_rgb_color; + + // Only validate the combined value once + if (rawRGB > 0 && rawRGB <= 255255255) { + // Extract each component as a normal int first + int r = (rawRGB >> 16) & 0xFF; + int g = (rawRGB >> 8) & 0xFF; + int b = rawRGB & 0xFF; + if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { + TFT_MESH = COLOR565(static_cast(r), static_cast(g), static_cast(b)); + } + } + +#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64) + dispdev = new SH1106Wire(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); +#elif defined(USE_ST7789) +#ifdef ESP_PLATFORM + dispdev = new ST7789Spi(&SPI1, ST7789_RESET, ST7789_RS, ST7789_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT, ST7789_SDA, + ST7789_MISO, ST7789_SCK); +#else + dispdev = new ST7789Spi(&SPI1, ST7789_RESET, ST7789_RS, ST7789_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT); +#endif +#elif defined(USE_ST7796) +#ifdef ESP_PLATFORM + dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT, ST7796_SDA, + ST7796_MISO, ST7796_SCK, TFT_SPI_FREQUENCY); +#else + dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT); +#endif +#elif defined(USE_SSD1306) + dispdev = new SSD1306Wire(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); +#elif defined(USE_SPISSD1306) + dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48); + if (!dispdev->init()) { + LOG_DEBUG("Error: SSD1306 not detected!"); + } else { + static_cast(dispdev)->setHorizontalOffset(32); + LOG_INFO("SSD1306 init success"); + } +#elif defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7789_CS) || \ + defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR) + dispdev = new TFTDisplay(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); +#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) + dispdev = new EInkDisplay(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); +#elif defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY) + dispdev = new EInkDynamicDisplay(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); +#elif defined(USE_ST7567) + dispdev = new ST7567Wire(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); +#elif ARCH_PORTDUINO + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + if (portduino_config.displayPanel != no_screen) { + LOG_DEBUG("Make TFTDisplay!"); + dispdev = new TFTDisplay(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); + } else { + dispdev = new AutoOLEDWire(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); + isAUTOOled = true; + } + } +#else + dispdev = new AutoOLEDWire(address.address, -1, -1, geometry, + (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); + isAUTOOled = true; +#endif + +#if defined(USE_ST7789) + static_cast(dispdev)->setRGB(TFT_MESH); +#elif defined(USE_ST7796) + static_cast(dispdev)->setRGB(TFT_MESH); +#endif + + ui = new OLEDDisplayUi(dispdev); + cmdQueue.setReader(this); +} + +Screen::~Screen() +{ + delete[] graphics::normalFrames; +} + +/** + * Prepare the display for the unit going to the lowest power mode possible. Most screens will just + * poweroff, but eink screens will show a "I'm sleeping" graphic, possibly with a QR code + */ +void Screen::doDeepSleep() +{ +#ifdef USE_EINK + setOn(false, graphics::UIRenderer::drawDeepSleepFrame); +#else + // Without E-Ink display: + setOn(false); +#endif +} + +void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) +{ + if (!useDisplay) + return; + + if (on != screenOn) { + if (on) { + LOG_INFO("Turn on screen"); + powerMon->setState(meshtastic_PowerMon_State_Screen_On); +#ifdef T_WATCH_S3 + PMU->enablePowerOutput(XPOWERS_ALDO2); +#endif + +#if defined(MUZI_BASE) + dispdev->init(); + dispdev->setBrightness(brightness); + dispdev->flipScreenVertically(); + dispdev->resetDisplay(); + digitalWrite(SCREEN_12V_ENABLE, HIGH); + delay(100); +#endif +#if !ARCH_PORTDUINO + dispdev->displayOn(); +#endif + +#ifdef PIN_EINK_EN + if (uiconfig.screen_brightness == 1) + digitalWrite(PIN_EINK_EN, HIGH); +#elif defined(PCA_PIN_EINK_EN) + if (uiconfig.screen_brightness > 0) + io.digitalWrite(PCA_PIN_EINK_EN, HIGH); +#endif + +#if defined(ST7789_CS) && \ + !defined(M5STACK) // set display brightness when turning on screens. Just moved function from TFTDisplay to here. + static_cast(dispdev)->setDisplayBrightness(brightness); +#endif + + dispdev->displayOn(); +#if defined(HELTEC_TRACKER_V1_X) || defined(HELTEC_WIRELESS_TRACKER_V2) + ui->init(); +#endif +#ifdef USE_ST7789 + pinMode(VTFT_CTRL, OUTPUT); + digitalWrite(VTFT_CTRL, LOW); + ui->init(); +#ifdef ESP_PLATFORM + analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT); +#else + pinMode(VTFT_LEDA, OUTPUT); + digitalWrite(VTFT_LEDA, TFT_BACKLIGHT_ON); +#endif +#endif +#ifdef USE_ST7796 + ui->init(); +#ifdef ESP_PLATFORM + analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT); +#else + pinMode(VTFT_LEDA, OUTPUT); + digitalWrite(VTFT_LEDA, TFT_BACKLIGHT_ON); +#endif +#endif + enabled = true; + setInterval(0); // Draw ASAP + runASAP = true; + } else { + powerMon->clearState(meshtastic_PowerMon_State_Screen_On); +#ifdef USE_EINK + // eInkScreensaver parameter is usually NULL (default argument), default frame used instead + setScreensaverFrames(einkScreensaver); +#endif + +#ifdef PIN_EINK_EN + digitalWrite(PIN_EINK_EN, LOW); +#elif defined(PCA_PIN_EINK_EN) + io.digitalWrite(PCA_PIN_EINK_EN, LOW); +#endif + + dispdev->displayOff(); + +#ifdef SCREEN_12V_ENABLE + digitalWrite(SCREEN_12V_ENABLE, LOW); +#endif +#ifdef USE_ST7789 + SPI1.end(); +#if defined(ARCH_ESP32) + pinMode(VTFT_LEDA, ANALOG); + pinMode(VTFT_CTRL, ANALOG); + pinMode(ST7789_RESET, ANALOG); + pinMode(ST7789_RS, ANALOG); + pinMode(ST7789_NSS, ANALOG); +#else + nrf_gpio_cfg_default(VTFT_LEDA); + nrf_gpio_cfg_default(VTFT_CTRL); + nrf_gpio_cfg_default(ST7789_RESET); + nrf_gpio_cfg_default(ST7789_RS); + nrf_gpio_cfg_default(ST7789_NSS); +#endif +#endif +#ifdef USE_ST7796 + SPI1.end(); +#if defined(ARCH_ESP32) + pinMode(VTFT_LEDA, OUTPUT); + digitalWrite(VTFT_LEDA, LOW); + pinMode(ST7796_RESET, ANALOG); + pinMode(ST7796_RS, ANALOG); + pinMode(ST7796_NSS, ANALOG); +#else + nrf_gpio_cfg_default(VTFT_LEDA); + nrf_gpio_cfg_default(ST7796_RESET); + nrf_gpio_cfg_default(ST7796_RS); + nrf_gpio_cfg_default(ST7796_NSS); +#endif +#endif + +#ifdef T_WATCH_S3 + PMU->disablePowerOutput(XPOWERS_ALDO2); +#endif + enabled = false; + } + screenOn = on; + } +} + +void Screen::setup() +{ + + // Enable display rendering + useDisplay = true; + + // Load saved brightness from UI config + // For OLED displays (SSD1306), default brightness is 255 if not set + if (uiconfig.screen_brightness == 0) { +#if defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) + brightness = 255; // Default for OLED +#else + brightness = BRIGHTNESS_DEFAULT; +#endif + } else { + brightness = uiconfig.screen_brightness; + } + + // Detect OLED subtype (if supported by board variant) +#ifdef AutoOLEDWire_h + if (isAUTOOled) + static_cast(dispdev)->setDetected(model); +#endif + +#if defined(USE_SH1107_128_64) || defined(USE_SH1107) + static_cast(dispdev)->setSubtype(7); +#endif + +#if defined(USE_ST7789) && defined(TFT_MESH) + // Apply custom RGB color (e.g. Heltec T114/T190) + static_cast(dispdev)->setRGB(TFT_MESH); +#endif +#if defined(MUZI_BASE) + dispdev->delayPoweron = true; +#endif +#if defined(USE_ST7796) && defined(TFT_MESH) + // Custom text color, if defined in variant.h + static_cast(dispdev)->setRGB(TFT_MESH); +#endif + + // Initialize display and UI system + ui->init(); + displayWidth = dispdev->width(); + displayHeight = dispdev->height(); + + ui->setTimePerTransition(0); // Disable animation delays + ui->setIndicatorPosition(BOTTOM); // Not used (indicators disabled below) + ui->setIndicatorDirection(LEFT_RIGHT); // Not used (indicators disabled below) + ui->setFrameAnimation(SLIDE_LEFT); // Used only when indicators are active + ui->disableAllIndicators(); // Disable page indicator dots + ui->getUiState()->userData = this; // Allow static callbacks to access Screen instance + + // Apply loaded brightness +#if defined(ST7789_CS) + static_cast(dispdev)->setDisplayBrightness(brightness); +#elif defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SPISSD1306) + dispdev->setBrightness(brightness); +#endif + LOG_INFO("Applied screen brightness: %d", brightness); + + // Set custom overlay callbacks + static OverlayCallback overlays[] = { + graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame + }; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + + // Enable UTF-8 to display mapping + dispdev->setFontTableLookupFunction(customFontTableLookup); + +#ifdef USERPREFS_OEM_TEXT + logo_timeout *= 2; // Give more time for branded boot logos +#endif + + // Configure alert frames (e.g., "Resuming..." or region name) + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip slow refresh + alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { +#ifdef ARCH_ESP32 + if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1) + graphics::UIRenderer::drawFrameText(display, state, x, y, "Resuming..."); + else +#endif + { + const char *region = myRegion ? myRegion->name : nullptr; + graphics::UIRenderer::drawIconScreen(region, display, state, x, y); + } + }; + ui->setFrames(alertFrames, 1); + ui->disableAutoTransition(); // Require manual navigation between frames + + // Log buffer for on-screen logs (3 lines max) + dispdev->setLogBuffer(3, 32); + + // Optional screen mirroring or flipping (e.g. for T-Beam orientation) +#ifdef SCREEN_MIRROR + dispdev->mirrorScreen(); +#else + if (!config.display.flip_screen) { +#if defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \ + defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR) + static_cast(dispdev)->flipScreenVertically(); +#elif defined(USE_ST7789) + static_cast(dispdev)->flipScreenVertically(); +#elif defined(USE_ST7796) + static_cast(dispdev)->mirrorScreen(); +#elif !defined(M5STACK_UNITC6L) + dispdev->flipScreenVertically(); +#endif + } +#endif + + // Generate device ID from MAC address + uint8_t dmac[6]; + getMacAddr(dmac); + snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]); + +#if ARCH_PORTDUINO + handleSetOn(false); // Ensure proper init for Arduino targets +#endif + + // Turn on display and trigger first draw + handleSetOn(true); + determineResolution(dispdev->height(), dispdev->width()); + ui->update(); +#ifndef USE_EINK + ui->update(); // Some SSD1306 clones drop the first draw, so run twice +#endif + serialSinceMsec = millis(); + +#if ARCH_PORTDUINO + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + if (portduino_config.touchscreenModule) { + touchScreenImpl1 = + new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch); + touchScreenImpl1->init(); + } + } +#elif HAS_TOUCHSCREEN && !defined(USE_EINK) && !HAS_CST226SE + touchScreenImpl1 = + new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch); + touchScreenImpl1->init(); +#endif + + // Subscribe to device status updates + powerStatusObserver.observe(&powerStatus->onNewStatus); + gpsStatusObserver.observe(&gpsStatus->onNewStatus); + nodeStatusObserver.observe(&nodeStatus->onNewStatus); + +#if !MESHTASTIC_EXCLUDE_ADMIN + adminMessageObserver.observe(adminModule); +#endif + if (inputBroker) + inputObserver.observe(inputBroker); + + // Load persisted messages into RAM + messageStore.loadFromFlash(); + LOG_INFO("MessageStore loaded from flash"); + + // Notify modules that support UI events + MeshModule::observeUIEvents(&uiFrameEventObserver); +} + +void Screen::setOn(bool on, FrameCallback einkScreensaver) +{ +#if defined(T_LORA_PAGER) + if (cardKbI2cImpl) + cardKbI2cImpl->toggleBacklight(on); +#endif + if (!on) + // We handle off commands immediately, because they might be called because the CPU is shutting down + handleSetOn(false, einkScreensaver); + else + enqueueCmd(ScreenCmd{.cmd = Cmd::SET_ON}); +} + +void Screen::forceDisplay(bool forceUiUpdate) +{ + // Nasty hack to force epaper updates for 'key' frames. FIXME, cleanup. +#ifdef USE_EINK + // If requested, make sure queued commands are run, and UI has rendered a new frame + if (forceUiUpdate) { + // Force a display refresh, in addition to the UI update + // Changing the GPS status bar icon apparently doesn't register as a change in image + // (False negative of the image hashing algorithm used to skip identical frames) + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); + + // No delay between UI frame rendering + setFastFramerate(); + + // Make sure all CMDs have run first + while (!cmdQueue.isEmpty()) + runOnce(); + + // Ensure at least one frame has drawn + uint64_t startUpdate; + do { + startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow.. + delay(10); + ui->update(); + } while (ui->getUiState()->lastUpdate < startUpdate); + + // Return to normal frame rate + targetFramerate = IDLE_FRAMERATE; + ui->setTargetFPS(targetFramerate); + } + + // Tell EInk class to update the display + static_cast(dispdev)->forceDisplay(); +#else + // No delay between UI frame rendering + if (forceUiUpdate) { + setFastFramerate(); + } +#endif +} + +static uint32_t lastScreenTransition; + +int32_t Screen::runOnce() +{ + // If we don't have a screen, don't ever spend any CPU for us. + if (!useDisplay) { + enabled = false; + return RUN_SAME; + } + + if (displayHeight == 0) { + displayHeight = dispdev->getHeight(); + } + + // Detect frame transitions and clear message cache when leaving text message screen + { + static int8_t lastFrameIndex = -1; + int8_t currentFrameIndex = ui->getUiState()->currentFrame; + int8_t textMsgIndex = framesetInfo.positions.textMessage; + + if (lastFrameIndex != -1 && currentFrameIndex != lastFrameIndex) { + + if (lastFrameIndex == textMsgIndex && currentFrameIndex != textMsgIndex) { + graphics::MessageRenderer::clearMessageCache(); + } + } + + lastFrameIndex = currentFrameIndex; + } + + menuHandler::handleMenuSwitch(dispdev); + + // Show boot screen for first logo_timeout seconds, then switch to normal operation. + // serialSinceMsec adjusts for additional serial wait time during nRF52 bootup + static bool showingBootScreen = true; + if (showingBootScreen && (millis() > (logo_timeout + serialSinceMsec))) { + LOG_INFO("Done with boot screen"); + stopBootScreen(); + showingBootScreen = false; + } + +#ifdef USERPREFS_OEM_TEXT + static bool showingOEMBootScreen = true; + if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) { + LOG_INFO("Switch to OEM screen..."); + // Change frames. + static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen}; + static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]); + ui->setFrames(bootOEMFrames, bootOEMFrameCount); + ui->update(); +#ifndef USE_EINK + ui->update(); +#endif + showingOEMBootScreen = false; + } +#endif + +#ifndef DISABLE_WELCOME_UNSET + if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { +#if defined(M5STACK_UNITC6L) + menuHandler::LoraRegionPicker(); +#else + menuHandler::OnboardMessage(); +#endif + } +#endif + if (!NotificationRenderer::isOverlayBannerShowing() && rebootAtMsec != 0) { + showSimpleBanner("Rebooting...", 0); + } + + // Process incoming commands. + for (;;) { + ScreenCmd cmd; + if (!cmdQueue.dequeue(&cmd, 0)) { + break; + } + switch (cmd.cmd) { + case Cmd::SET_ON: + handleSetOn(true); + break; + case Cmd::SET_OFF: + handleSetOn(false); + break; + case Cmd::ON_PRESS: + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + showFrame(FrameDirection::NEXT); + } + break; + case Cmd::SHOW_PREV_FRAME: + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + showFrame(FrameDirection::PREVIOUS); + } + break; + case Cmd::SHOW_NEXT_FRAME: + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + showFrame(FrameDirection::NEXT); + } + break; + case Cmd::START_ALERT_FRAME: { + showingBootScreen = false; // this should avoid the edge case where an alert triggers before the boot screen goes away + showingNormalScreen = false; + NotificationRenderer::pauseBanner = true; + alertFrames[0] = alertFrame; +#ifdef USE_EINK + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please + EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update + handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?) +#endif + setFrameImmediateDraw(alertFrames); + break; + } + case Cmd::START_FIRMWARE_UPDATE_SCREEN: + handleStartFirmwareUpdateScreen(); + break; + case Cmd::STOP_ALERT_FRAME: + NotificationRenderer::pauseBanner = false; + break; + case Cmd::STOP_BOOT_SCREEN: + EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + setFrames(); + } + break; + case Cmd::NOOP: + break; + default: + LOG_ERROR("Invalid screen cmd"); + } + } + + if (!screenOn) { // If we didn't just wake and the screen is still off, then + // stop updating until it is on again + enabled = false; + return 0; + } + + // this must be before the frameState == FIXED check, because we always + // want to draw at least one FIXED frame before doing forceDisplay + ui->update(); + + // Switch to a low framerate (to save CPU) when we are not in transition + // but we should only call setTargetFPS when framestate changes, because + // otherwise that breaks animations. + + if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) { + // oldFrameState = ui->getUiState()->frameState; + targetFramerate = IDLE_FRAMERATE; + + ui->setTargetFPS(targetFramerate); + forceDisplay(); + } + + // While showing the bootscreen or Bluetooth pair screen all of our + // standard screen switching is stopped. + if (showingNormalScreen) { + // standard screen loop handling here + if (config.display.auto_screen_carousel_secs > 0 && + NotificationRenderer::current_notification_type != notificationTypeEnum::text_input && + !Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) { + + // If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead + // Carousel is potentially a major source of E-Ink display wear +#if !defined(EINK_BACKGROUND_USES_FAST) + EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); +#endif + + LOG_DEBUG("LastScreenTransition exceeded %ums transition to next frame", (millis() - lastScreenTransition)); + handleOnPress(); + } + } + + // LOG_DEBUG("want fps %d, fixed=%d", targetFramerate, + // ui->getUiState()->frameState); If we are scrolling we need to be called + // soon, otherwise just 1 fps (to save CPU) We also ask to be called twice + // as fast as we really need so that any rounding errors still result with + // the correct framerate + return (1000 / targetFramerate); +} + +/* show a message that the SSL cert is being built + * it is expected that this will be used during the boot phase */ +void Screen::setSSLFrames() +{ + if (address_found.address) { + // LOG_DEBUG("Show SSL frames"); + static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen}; + ui->setFrames(sslFrames, 1); + ui->update(); + } +} + +#ifdef USE_EINK +/// Determine which screensaver frame to use, then set the FrameCallback +void Screen::setScreensaverFrames(FrameCallback einkScreensaver) +{ + // Retain specified frame / overlay callback beyond scope of this method + static FrameCallback screensaverFrame; + static OverlayCallback screensaverOverlay; + +#if defined(HAS_EINK_ASYNCFULL) && defined(USE_EINK_DYNAMICDISPLAY) + // Join (await) a currently running async refresh, then run the post-update code. + // Avoid skipping of screensaver frame. Would otherwise be handled by NotifiedWorkerThread. + EINK_JOIN_ASYNCREFRESH(dispdev); +#endif + + // If: one-off screensaver frame passed as argument. Handles doDeepSleep() + if (einkScreensaver != NULL) { + screensaverFrame = einkScreensaver; + ui->setFrames(&screensaverFrame, 1); + } + + // Else, display the usual "overlay" screensaver + else { + screensaverOverlay = graphics::UIRenderer::drawScreensaverOverlay; + ui->setOverlays(&screensaverOverlay, 1); + } + + // Request new frame, ASAP + setFastFramerate(); + uint64_t startUpdate; + do { + startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow.. + delay(1); + ui->update(); + } while (ui->getUiState()->lastUpdate < startUpdate); + + // Old EInkDisplay class +#if !defined(USE_EINK_DYNAMICDISPLAY) + static_cast(dispdev)->forceDisplay(0); // Screen::forceDisplay(), but override rate-limit +#endif + + // Prepare now for next frame, shown when display wakes + ui->setOverlays(NULL, 0); // Clear overlay + setFrames(FOCUS_PRESERVE); // Return to normal display updates, showing same frame as before screensaver, ideally + + // Pick a refresh method, for when display wakes +#ifdef EINK_HASQUIRK_GHOSTING + EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // Really ugly to see ghosting from "screen paused" +#else + EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh +#endif +} +#endif + +// Regenerate the normal set of frames, focusing a specific frame if requested +// Called when a frame should be added / removed, or custom frames should be cleared +void Screen::setFrames(FrameFocus focus) +{ + // Block setFrames calls when virtual keyboard is active to prevent overlay interference + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + return; + } + + uint8_t originalPosition = ui->getUiState()->currentFrame; + uint8_t previousFrameCount = framesetInfo.frameCount; + FramesetInfo fsi; // Location of specific frames, for applying focus parameter + + graphics::UIRenderer::rebuildFavoritedNodes(); + + LOG_DEBUG("Show standard frames"); + showingNormalScreen = true; + + indicatorIcons.clear(); + + size_t numframes = 0; + + // If we have a critical fault, show it first + fsi.positions.fault = numframes; + if (error_code) { + normalFrames[numframes++] = NotificationRenderer::drawCriticalFaultFrame; + indicatorIcons.push_back(icon_error); + focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame + } + +#if defined(DISPLAY_CLOCK_FRAME) + if (!hiddenFrames.clock) { + fsi.positions.clock = numframes; +#if defined(M5STACK_UNITC6L) + normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame; +#else + normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame + : graphics::ClockRenderer::drawDigitalClockFrame; +#endif + indicatorIcons.push_back(digital_icon_clock); + } +#endif + + if (!hiddenFrames.home) { + fsi.positions.home = numframes; + normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused; + indicatorIcons.push_back(icon_home); + } + + fsi.positions.textMessage = numframes; + normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame; + indicatorIcons.push_back(icon_mail); + +#ifndef USE_EINK + if (!hiddenFrames.nodelist_nodes) { + fsi.positions.nodelist_nodes = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Nodes; + indicatorIcons.push_back(icon_nodes); + } + if (!hiddenFrames.nodelist_location) { + fsi.positions.nodelist_location = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Location; + indicatorIcons.push_back(icon_list); + } +#endif + +// Show detailed node views only on E-Ink builds +#ifdef USE_EINK + if (!hiddenFrames.nodelist_lastheard) { + fsi.positions.nodelist_lastheard = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen; + indicatorIcons.push_back(icon_nodes); + } + if (!hiddenFrames.nodelist_hopsignal) { + fsi.positions.nodelist_hopsignal = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen; + indicatorIcons.push_back(icon_signal); + } + if (!hiddenFrames.nodelist_distance) { + fsi.positions.nodelist_distance = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen; + indicatorIcons.push_back(icon_distance); + } +#endif +#if HAS_GPS +#ifdef USE_EINK + if (!hiddenFrames.nodelist_bearings) { + fsi.positions.nodelist_bearings = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; + indicatorIcons.push_back(icon_list); + } +#endif + if (!hiddenFrames.gps) { + fsi.positions.gps = numframes; + normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen; + indicatorIcons.push_back(icon_compass); + } +#endif + if (RadioLibInterface::instance && !hiddenFrames.lora) { + fsi.positions.lora = numframes; + normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused; + indicatorIcons.push_back(icon_radio); + } + if (!hiddenFrames.system) { + fsi.positions.system = numframes; + normalFrames[numframes++] = graphics::DebugRenderer::drawSystemScreen; + indicatorIcons.push_back(icon_system); + } +#if !defined(DISPLAY_CLOCK_FRAME) + if (!hiddenFrames.clock) { + fsi.positions.clock = numframes; + normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame + : graphics::ClockRenderer::drawDigitalClockFrame; + indicatorIcons.push_back(digital_icon_clock); + } +#endif + if (!hiddenFrames.chirpy) { + fsi.positions.chirpy = numframes; + normalFrames[numframes++] = graphics::DebugRenderer::drawChirpy; + indicatorIcons.push_back(chirpy_small); + } + +#if HAS_WIFI && !defined(ARCH_PORTDUINO) + if (!hiddenFrames.wifi && isWifiAvailable()) { + fsi.positions.wifi = numframes; + normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoWiFiTrampoline; + indicatorIcons.push_back(icon_wifi); + } +#endif + + // Beware of what changes you make in this code! + // We pass numframes into GetMeshModulesWithUIFrames() which is highly important! + // Inside of that callback, goes over to MeshModule.cpp and we run + // modulesWithUIFrames.resize(startIndex, nullptr), to insert nullptr + // entries until we're ready to start building the matching entries. + // We are doing our best to keep the normalFrames vector + // and the moduleFrames vector in lock step. + moduleFrames = MeshModule::GetMeshModulesWithUIFrames(numframes); + LOG_DEBUG("Show %d module frames", moduleFrames.size()); + + for (auto i = moduleFrames.begin(); i != moduleFrames.end(); ++i) { + // Draw the module frame, using the hack described above + if (*i != nullptr) { + normalFrames[numframes] = drawModuleFrame; + + // Check if the module being drawn has requested focus + // We will honor this request later, if setFrames was triggered by a UIFrameEvent + MeshModule *m = *i; + if (m && m->isRequestingFocus()) + fsi.positions.focusedModule = numframes; + if (m && m == waypointModule) + fsi.positions.waypoint = numframes; + + indicatorIcons.push_back(icon_module); + numframes++; + } + } + + LOG_DEBUG("Added modules. numframes: %d", numframes); + + // We don't show the node info of our node (if we have it yet - we should) + size_t numMeshNodes = nodeDB->getNumMeshNodes(); + if (numMeshNodes > 0) + numMeshNodes--; + + if (!hiddenFrames.show_favorites) { + // Temporary array to hold favorite node frames + std::vector favoriteFrames; + + for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); + if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) { + favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo); + } + } + + // Insert favorite frames *after* collecting them all + if (!favoriteFrames.empty()) { + fsi.positions.firstFavorite = numframes; + for (const auto &f : favoriteFrames) { + normalFrames[numframes++] = f; + indicatorIcons.push_back(icon_node); + } + fsi.positions.lastFavorite = numframes - 1; + } else { + fsi.positions.firstFavorite = 255; + fsi.positions.lastFavorite = 255; + } + } + + fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE + this->frameCount = numframes; // Save frame count for use in custom overlay + LOG_DEBUG("Finished build frames. numframes: %d", numframes); + + ui->setFrames(normalFrames, numframes); + ui->disableAllIndicators(); + + // Add overlays: frame icons and alert banner) + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + + prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list just changed) + + // Focus on a specific frame, in the frame set we just created + switch (focus) { + case FOCUS_DEFAULT: + ui->switchToFrame(fsi.positions.deviceFocused); + break; + case FOCUS_FAULT: + ui->switchToFrame(fsi.positions.fault); + break; + case FOCUS_MODULE: + // Whichever frame was marked by MeshModule::requestFocus(), if any + // If no module requested focus, will show the first frame instead + ui->switchToFrame(fsi.positions.focusedModule); + break; + case FOCUS_CLOCK: + // Whichever frame was marked by MeshModule::requestFocus(), if any + // If no module requested focus, will show the first frame instead + ui->switchToFrame(fsi.positions.clock); + break; + case FOCUS_SYSTEM: + ui->switchToFrame(fsi.positions.system); + break; + + case FOCUS_PRESERVE: + // No more adjustment — force stay on same index + if (previousFrameCount > fsi.frameCount) { + ui->switchToFrame(originalPosition - 1); + } else if (previousFrameCount < fsi.frameCount) { + ui->switchToFrame(originalPosition + 1); + } else { + ui->switchToFrame(originalPosition); + } + break; + } + + // Store the info about this frameset, for future setFrames calls + this->framesetInfo = fsi; + + setFastFramerate(); // Draw ASAP +} + +void Screen::setFrameImmediateDraw(FrameCallback *drawFrames) +{ + ui->disableAllIndicators(); + ui->setFrames(drawFrames, 1); + setFastFramerate(); +} + +void Screen::toggleFrameVisibility(const std::string &frameName) +{ +#ifndef USE_EINK + if (frameName == "nodelist_nodes") { + hiddenFrames.nodelist_nodes = !hiddenFrames.nodelist_nodes; + } + if (frameName == "nodelist_location") { + hiddenFrames.nodelist_location = !hiddenFrames.nodelist_location; + } +#endif +#ifdef USE_EINK + if (frameName == "nodelist_lastheard") { + hiddenFrames.nodelist_lastheard = !hiddenFrames.nodelist_lastheard; + } + if (frameName == "nodelist_hopsignal") { + hiddenFrames.nodelist_hopsignal = !hiddenFrames.nodelist_hopsignal; + } + if (frameName == "nodelist_distance") { + hiddenFrames.nodelist_distance = !hiddenFrames.nodelist_distance; + } +#endif +#if HAS_GPS +#ifdef USE_EINK + if (frameName == "nodelist_bearings") { + hiddenFrames.nodelist_bearings = !hiddenFrames.nodelist_bearings; + } +#endif + if (frameName == "gps") { + hiddenFrames.gps = !hiddenFrames.gps; + } +#endif + if (frameName == "lora") { + hiddenFrames.lora = !hiddenFrames.lora; + } + if (frameName == "clock") { + hiddenFrames.clock = !hiddenFrames.clock; + } + if (frameName == "show_favorites") { + hiddenFrames.show_favorites = !hiddenFrames.show_favorites; + } + if (frameName == "chirpy") { + hiddenFrames.chirpy = !hiddenFrames.chirpy; + } +} + +bool Screen::isFrameHidden(const std::string &frameName) const +{ +#ifndef USE_EINK + if (frameName == "nodelist_nodes") + return hiddenFrames.nodelist_nodes; + if (frameName == "nodelist_location") + return hiddenFrames.nodelist_location; +#endif +#ifdef USE_EINK + if (frameName == "nodelist_lastheard") + return hiddenFrames.nodelist_lastheard; + if (frameName == "nodelist_hopsignal") + return hiddenFrames.nodelist_hopsignal; + if (frameName == "nodelist_distance") + return hiddenFrames.nodelist_distance; +#endif +#if HAS_GPS +#ifdef USE_EINK + if (frameName == "nodelist_bearings") + return hiddenFrames.nodelist_bearings; +#endif + if (frameName == "gps") + return hiddenFrames.gps; +#endif + if (frameName == "lora") + return hiddenFrames.lora; + if (frameName == "clock") + return hiddenFrames.clock; + if (frameName == "show_favorites") + return hiddenFrames.show_favorites; + if (frameName == "chirpy") + return hiddenFrames.chirpy; + + return false; +} + +void Screen::handleStartFirmwareUpdateScreen() +{ + LOG_DEBUG("Show firmware screen"); + showingNormalScreen = false; + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // E-Ink: Explicitly use fast-refresh for next frame + + static FrameCallback frames[] = {graphics::NotificationRenderer::drawFrameFirmware}; + setFrameImmediateDraw(frames); +} + +void Screen::blink() +{ + setFastFramerate(); + uint8_t count = 10; + dispdev->setBrightness(254); + while (count > 0) { + dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight()); + dispdev->display(); + delay(50); + dispdev->clear(); + dispdev->display(); + delay(50); + count = count - 1; + } + // The dispdev->setBrightness does not work for t-deck display, it seems to run the setBrightness function in + // OLEDDisplay. + dispdev->setBrightness(brightness); +} + +void Screen::increaseBrightness() +{ + brightness = ((brightness + 62) > 254) ? brightness : (brightness + 62); + +#if defined(ST7789_CS) + // run the setDisplayBrightness function. This works on t-decks + static_cast(dispdev)->setDisplayBrightness(brightness); +#endif + + /* TO DO: add little popup in center of screen saying what brightness level it is set to*/ +} + +void Screen::decreaseBrightness() +{ + brightness = (brightness < 70) ? brightness : (brightness - 62); + +#if defined(ST7789_CS) + static_cast(dispdev)->setDisplayBrightness(brightness); +#endif + + /* TO DO: add little popup in center of screen saying what brightness level it is set to*/ +} + +void Screen::handleOnPress() +{ + // If screen was off, just wake it, otherwise advance to next frame + // If we are in a transition, the press must have bounced, drop it. + if (ui->getUiState()->frameState == FIXED) { + ui->nextFrame(); + lastScreenTransition = millis(); + setFastFramerate(); + } +} + +void Screen::showFrame(FrameDirection direction) +{ + // Only advance frames when UI is stable + if (ui->getUiState()->frameState == FIXED) { + + if (direction == FrameDirection::NEXT) { + ui->nextFrame(); + } else { + ui->previousFrame(); + } + + lastScreenTransition = millis(); + setFastFramerate(); + } +} + +#ifndef SCREEN_TRANSITION_FRAMERATE +#define SCREEN_TRANSITION_FRAMERATE 30 // fps +#endif + +void Screen::setFastFramerate() +{ +#if defined(M5STACK_UNITC6L) + dispdev->clear(); + dispdev->display(); +#endif + // We are about to start a transition so speed up fps + targetFramerate = SCREEN_TRANSITION_FRAMERATE; + + ui->setTargetFPS(targetFramerate); + setInterval(0); // redraw ASAP + runASAP = true; +} + +int Screen::handleStatusUpdate(const meshtastic::Status *arg) +{ + switch (arg->getStatusType()) { + case STATUS_TYPE_NODE: + if (showingNormalScreen && nodeStatus->getLastNumTotal() != nodeStatus->getNumTotal()) { + setFrames(FOCUS_PRESERVE); // Regen the list of screen frames (returning to same frame, if possible) + } + nodeDB->updateGUI = false; + break; + case STATUS_TYPE_POWER: + forceDisplay(true); + break; + } + + return 0; +} + +// Handles when message is received; will jump to text message frame. +int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) +{ + if (showingNormalScreen) { + if (packet->from == 0) { + // Outgoing message (likely sent from phone) + devicestate.has_rx_text_message = false; + memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message)); + hiddenFrames.textMessage = true; + hasUnreadMessage = false; // Clear unread state when user replies + + setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list + } else { + // Incoming message + devicestate.has_rx_text_message = true; // Needed to include the message frame + hasUnreadMessage = true; // Enables mail icon in the header + setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view (no-op during text_input) + + // Only wake/force display if the configuration allows it + if (shouldWakeOnReceivedMessage()) { + setOn(true); // Wake up the screen first + forceDisplay(); // Forces screen redraw + } + // === Prepare banner/popup content === + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from); + const meshtastic_Channel channel = + channels.getByIndex(packet->channel ? packet->channel : channels.getPrimaryIndex()); + const char *longName = (node && node->has_user) ? node->user.long_name : nullptr; + + const char *msgRaw = reinterpret_cast(packet->decoded.payload.bytes); + + char banner[256]; + + bool isAlert = false; + + if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra || + moduleConfig.external_notification.alert_bell_buzzer) + // Check for bell character to determine if this message is an alert + for (size_t i = 0; i < packet->decoded.payload.size && i < 100; i++) { + if (msgRaw[i] == ASCII_BELL) { + isAlert = true; + break; + } + } + + // Unlike generic messages, alerts (when enabled via the ext notif module) ignore any + // 'mute' preferences set to any specific node or channel. + // If on-screen keyboard is active, show a transient popup over keyboard instead of interrupting it + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + // Wake and force redraw so popup is visible immediately + if (shouldWakeOnReceivedMessage()) { + setOn(true); + forceDisplay(); + } + + // Build popup: title = message source name, content = message text (sanitized) + // Title + char titleBuf[64] = {0}; + if (longName && longName[0]) { + // Sanitize sender name + std::string t = sanitizeString(longName); + strncpy(titleBuf, t.c_str(), sizeof(titleBuf) - 1); + } else { + strncpy(titleBuf, "Message", sizeof(titleBuf) - 1); + } + + // Content: payload bytes may not be null-terminated, remove ASCII_BELL and sanitize + char content[256] = {0}; + { + std::string raw; + raw.reserve(packet->decoded.payload.size); + for (size_t i = 0; i < packet->decoded.payload.size; ++i) { + char c = msgRaw[i]; + if (c == ASCII_BELL) + continue; // strip bell + raw.push_back(c); + } + std::string sanitized = sanitizeString(raw); + strncpy(content, sanitized.c_str(), sizeof(content) - 1); + } + + NotificationRenderer::showKeyboardMessagePopupWithTitle(titleBuf, content, 3000); + +// Maintain existing buzzer behavior on M5 if applicable +#if defined(M5STACK_UNITC6L) + if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY || + (isAlert && moduleConfig.external_notification.alert_bell_buzzer) || + (!isBroadcast(packet->to) && isToUs(packet))) { + playLongBeep(); + } +#endif + } else { + // No keyboard active: use regular banner flow, respecting mute settings + if (isAlert) { + if (longName && longName[0]) { + snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName); + } else { + strcpy(banner, "Alert Received"); + } + screen->showSimpleBanner(banner, 3000); + } else if (!channel.settings.has_module_settings || !channel.settings.module_settings.is_muted) { + if (longName && longName[0]) { +#if defined(M5STACK_UNITC6L) + strcpy(banner, "New Message"); +#else + snprintf(banner, sizeof(banner), "New Message from\n%s", longName); +#endif + } else { + strcpy(banner, "New Message"); + } +#if defined(M5STACK_UNITC6L) + screen->setOn(true); + screen->showSimpleBanner(banner, 1500); + if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY || + (isAlert && moduleConfig.external_notification.alert_bell_buzzer) || + (!isBroadcast(packet->to) && isToUs(packet))) { + // Beep if not in DIRECT_MSG_ONLY mode or if in DIRECT_MSG_ONLY mode and either + // - packet contains an alert and alert bell buzzer is enabled + // - packet is a non-broadcast that is addressed to this node + playLongBeep(); + } +#else + screen->showSimpleBanner(banner, 3000); +#endif + } + } + } + } + + return 0; +} + +// Triggered by MeshModules +int Screen::handleUIFrameEvent(const UIFrameEvent *event) +{ + // Block UI frame events when virtual keyboard is active + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + return 0; + } + + if (showingNormalScreen) { + // Regenerate the frameset, potentially honoring a module's internal requestFocus() call + if (event->action == UIFrameEvent::Action::REGENERATE_FRAMESET) { + setFrames(FOCUS_MODULE); + } + + // Regenerate the frameset, while attempting to maintain focus on the current frame + else if (event->action == UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND) { + setFrames(FOCUS_PRESERVE); + } + + // Don't regenerate the frameset, just re-draw whatever is on screen ASAP + else if (event->action == UIFrameEvent::Action::REDRAW_ONLY) { + setFastFramerate(); + } + + // Jump directly to the Text Message screen + else if (event->action == UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE) { + setFrames(FOCUS_PRESERVE); // preserve current frame ordering + ui->switchToFrame(framesetInfo.positions.textMessage); + setFastFramerate(); // force redraw ASAP + } + } + + return 0; +} + +int Screen::handleInputEvent(const InputEvent *event) +{ + LOG_INPUT("Screen Input event %u! kb %u", event->inputEvent, event->kbchar); + if (!screenOn) + return 0; + + // Handle text input notifications specially - pass input to virtual keyboard + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + NotificationRenderer::inEvent = *event; + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + setFastFramerate(); // Draw ASAP + ui->update(); + return 0; + } + +#ifdef USE_EINK // the screen is the last input handler, so if an event makes it here, we can assume it will prompt a screen draw. + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please + EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update + handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?) + setFastFramerate(); // Draw ASAP +#endif + if (NotificationRenderer::isOverlayBannerShowing()) { + NotificationRenderer::inEvent = *event; + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + setFastFramerate(); // Draw ASAP + ui->update(); + + menuHandler::handleMenuSwitch(dispdev); + return 0; + } + // UP/DOWN in message screen scrolls through message threads + if (ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { + + if (event->inputEvent == INPUT_BROKER_UP) { + if (messageStore.getMessages().empty()) { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); + } else { + graphics::MessageRenderer::scrollUp(); + setFastFramerate(); // match existing behavior + return 0; + } + } + + if (event->inputEvent == INPUT_BROKER_DOWN) { + if (messageStore.getMessages().empty()) { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); + } else { + graphics::MessageRenderer::scrollDown(); + setFastFramerate(); + return 0; + } + } + } + // Use left or right input from a keyboard to move between frames, + // so long as a mesh module isn't using these events for some other purpose + if (showingNormalScreen) { + + // Ask any MeshModules if they're handling keyboard input right now + bool inputIntercepted = false; + for (MeshModule *module : moduleFrames) { + if (module && module->interceptingKeyboardInput()) + inputIntercepted = true; + } + + // If no modules are using the input, move between frames + if (!inputIntercepted) { +#if defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2 + bool handledEncoderScroll = false; + const bool isTextMessageFrame = (framesetInfo.positions.textMessage != 255 && + this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && + !messageStore.getMessages().empty()); + if (isTextMessageFrame) { + if (event->inputEvent == INPUT_BROKER_UP_LONG) { + graphics::MessageRenderer::nudgeScroll(-1); + handledEncoderScroll = true; + } else if (event->inputEvent == INPUT_BROKER_DOWN_LONG) { + graphics::MessageRenderer::nudgeScroll(1); + handledEncoderScroll = true; + } + } + + if (handledEncoderScroll) { + setFastFramerate(); + return 0; + } +#endif + if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_ALT_PRESS) { + showFrame(FrameDirection::PREVIOUS); + } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS) { + showFrame(FrameDirection::NEXT); + } else if (event->inputEvent == INPUT_BROKER_UP_LONG) { + // Long press up button for fast frame switching + showPrevFrame(); + } else if (event->inputEvent == INPUT_BROKER_DOWN_LONG) { + // Long press down button for fast frame switching + showNextFrame(); + } else if ((event->inputEvent == INPUT_BROKER_UP || event->inputEvent == INPUT_BROKER_DOWN) && + this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { + cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); + } else if (event->inputEvent == INPUT_BROKER_SELECT) { + if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { + menuHandler::homeBaseMenu(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.system) { + menuHandler::systemBaseMenu(); +#if HAS_GPS + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.gps && gps) { + menuHandler::positionBaseMenu(); +#endif + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.clock) { + menuHandler::clockMenu(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) { + menuHandler::loraMenu(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { + if (!messageStore.getMessages().empty()) { + menuHandler::messageResponseMenu(); + } else { +#if defined(M5STACK_UNITC6L) + menuHandler::textMessageMenu(); +#else + menuHandler::textMessageBaseMenu(); +#endif + } + } else if (framesetInfo.positions.firstFavorite != 255 && + this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && + this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { + menuHandler::favoriteBaseMenu(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_nodes || + this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_location || + this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_lastheard || + this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_hopsignal || + this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_distance || + this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_hopsignal || + this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_bearings) { + menuHandler::nodeListMenu(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.wifi) { + menuHandler::wifiBaseMenu(); + } + } else if (event->inputEvent == INPUT_BROKER_BACK) { + showFrame(FrameDirection::PREVIOUS); + } else if (event->inputEvent == INPUT_BROKER_CANCEL) { + setOn(false); + } + } + } + + return 0; +} + +int Screen::handleAdminMessage(AdminModule_ObserverData *arg) +{ + switch (arg->request->which_payload_variant) { + // Node removed manually (i.e. via app) + case meshtastic_AdminMessage_remove_by_nodenum_tag: + setFrames(FOCUS_PRESERVE); + *arg->result = AdminMessageHandleResult::HANDLED; + break; + + // Default no-op, in case the admin message observable gets used by other classes in future + default: + break; + } + return 0; +} + +bool Screen::isOverlayBannerShowing() +{ + return NotificationRenderer::isOverlayBannerShowing(); +} + +} // namespace graphics + +#else +graphics::Screen::Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY) {} +#endif // HAS_SCREEN + +bool shouldWakeOnReceivedMessage() +{ + /* + The goal here is to determine when we do NOT wake up the screen on message received: + - Any ext. notifications are turned on + - If role is not CLIENT / CLIENT_MUTE / CLIENT_HIDDEN / CLIENT_BASE + - If the battery level is very low + */ + if (moduleConfig.external_notification.enabled) { + return false; + } + if (!IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_CLIENT, + meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE, meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN, + meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) { + return false; + } + if (powerStatus && powerStatus->getBatteryChargePercent() < 10) { + return false; + } + return true; +} diff --git a/Screen.h b/Screen.h new file mode 100644 index 00000000000..4bb80897093 --- /dev/null +++ b/Screen.h @@ -0,0 +1,743 @@ +#pragma once + +#include "configuration.h" + +#include "detect/ScanI2C.h" +#include "mesh/generated/meshtastic/config.pb.h" +#include +#include +#include +#include + +#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) +namespace graphics +{ +enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, text_input }; + +struct BannerOverlayOptions { + const char *message; + uint32_t durationMs = 30000; + const char **optionsArrayPtr = nullptr; + const int *optionsEnumPtr = nullptr; + uint8_t optionsCount = 0; + std::function bannerCallback = nullptr; + int8_t InitialSelected = 0; + notificationTypeEnum notificationType = notificationTypeEnum::text_banner; +}; +} // namespace graphics + +bool shouldWakeOnReceivedMessage(); + +#if !HAS_SCREEN +#include "power.h" +namespace graphics +{ +// Noop class for boards without screen. +class Screen +{ + public: + enum FrameFocus : uint8_t { + FOCUS_DEFAULT, // No specific frame + FOCUS_PRESERVE, // Return to the previous frame + FOCUS_FAULT, + FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus + FOCUS_CLOCK, + FOCUS_SYSTEM, + }; + + explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY); + void onPress() {} + void setup() {} + void setOn(bool) {} + void doDeepSleep() {} + void forceDisplay(bool forceUiUpdate = false) {} + void startFirmwareUpdateScreen() {} + void increaseBrightness() {} + void decreaseBrightness() {} + void startAlert(const char *) {} + void showSimpleBanner(const char *message, uint32_t durationMs = 0) {} + void showOverlayBanner(BannerOverlayOptions) {} + void setFrames(FrameFocus focus) {} + void endAlert() {} +}; +} // namespace graphics +#else +#include + +#include + +#include "../configuration.h" +#include "gps/GeoCoord.h" +#include "graphics/ScreenFonts.h" + +#ifdef USE_ST7567 +#include +#elif defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64) +#include +#elif defined(USE_SSD1306) +#include +#elif defined(USE_ST7789) +#include +#elif defined(USE_SPISSD1306) +#include +#elif defined(USE_ST7796) +#include +#else +// the SH1106/SSD1306 variant is auto-detected +#include +#endif + +#include "EInkDisplay2.h" +#include "EInkDynamicDisplay.h" +#include "PointStruct.h" +#include "TFTDisplay.h" +#include "TypedQueue.h" +#include "commands.h" +#include "concurrency/LockGuard.h" +#include "concurrency/OSThread.h" +#include "graphics/draw/MenuHandler.h" +#include "input/InputBroker.h" +#include "mesh/MeshModule.h" +#include "modules/AdminModule.h" +#include "power.h" +#include +#include + +// 0 to 255, though particular variants might define different defaults +#ifndef BRIGHTNESS_DEFAULT +#define BRIGHTNESS_DEFAULT 150 +#endif + +// Meters to feet conversion +#ifndef METERS_TO_FEET +#define METERS_TO_FEET 3.28 +#endif + +// Feet to miles conversion +#ifndef MILES_TO_FEET +#define MILES_TO_FEET 5280 +#endif + +// Intuitive colors. E-Ink display is inverted from OLED(?) +#define EINK_BLACK OLEDDISPLAY_COLOR::WHITE +#define EINK_WHITE OLEDDISPLAY_COLOR::BLACK + +// Base segment dimensions for T-Watch segmented display +#define SEGMENT_WIDTH 16 +#define SEGMENT_HEIGHT 4 + +/// Convert an integer GPS coords to a floating point +#define DegD(i) (i * 1e-7) +extern bool hasUnreadMessage; +namespace +{ +/// A basic 2D point class for drawing +class Point +{ + public: + float x, y; + + Point(float _x, float _y) : x(_x), y(_y) {} + + /// Apply a rotation around zero (standard rotation matrix math) + void rotate(float radian) + { + float cos = cosf(radian), sin = sinf(radian); + float rx = x * cos + y * sin, ry = -x * sin + y * cos; + + x = rx; + y = ry; + } + + void translate(int16_t dx, int dy) + { + x += dx; + y += dy; + } + + void scale(float f) + { + // We use -f here to counter the flip that happens + // on the y axis when drawing and rotating on screen + x *= f; + y *= -f; + } +}; + +} // namespace + +namespace graphics +{ + +enum class FrameDirection { NEXT, PREVIOUS }; + +// Forward declarations +class Screen; + +/// Handles gathering and displaying debug information. +class DebugInfo +{ + public: + DebugInfo(const DebugInfo &) = delete; + DebugInfo &operator=(const DebugInfo &) = delete; + + private: + friend Screen; + + DebugInfo() {} + + /// Renders the debug screen. + void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + + /// Protects all of internal state. + concurrency::Lock lock; +}; + +/** + * @brief This class deals with showing things on the screen of the device. + * + * @details Other than setup(), this class is thread-safe as long as drawFrame is not called + * multiple times simultaneously. All state-changing calls are queued and executed + * when the main loop calls us. + */ +class Screen : public concurrency::OSThread +{ + CallbackObserver powerStatusObserver = + CallbackObserver(this, &Screen::handleStatusUpdate); + CallbackObserver gpsStatusObserver = + CallbackObserver(this, &Screen::handleStatusUpdate); + CallbackObserver nodeStatusObserver = + CallbackObserver(this, &Screen::handleStatusUpdate); + CallbackObserver uiFrameEventObserver = + CallbackObserver(this, &Screen::handleUIFrameEvent); // Sent by Mesh Modules + CallbackObserver inputObserver = + CallbackObserver(this, &Screen::handleInputEvent); + CallbackObserver adminMessageObserver = + CallbackObserver(this, &Screen::handleAdminMessage); + + public: + OLEDDisplay *getDisplayDevice() { return dispdev; } + explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY); + + // Screen dimension accessors + inline int getHeight() const { return displayHeight; } + inline int getWidth() const { return displayWidth; } + size_t frameCount = 0; // Total number of active frames + ~Screen(); + + // Which frame we want to be displayed, after we regen the frameset by calling setFrames + enum FrameFocus : uint8_t { + FOCUS_DEFAULT, // No specific frame + FOCUS_PRESERVE, // Return to the previous frame + FOCUS_FAULT, + FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus + FOCUS_CLOCK, + FOCUS_SYSTEM, + }; + + // Regenerate the normal set of frames, focusing a specific frame if requested + // Call when a frame should be added / removed, or custom frames should be cleared + void setFrames(FrameFocus focus = FOCUS_DEFAULT); + + std::vector indicatorIcons; // Per-frame custom icon pointers + Screen(const Screen &) = delete; + Screen &operator=(const Screen &) = delete; + + ScanI2C::DeviceAddress address_found; + meshtastic_Config_DisplayConfig_OledType model; + OLEDDISPLAY_GEOMETRY geometry; + + bool isOverlayBannerShowing(); + + bool isScreenOn() { return screenOn; } + + // Stores the last 4 of our hardware ID, to make finding the device for pairing easier + // FIXME: Needs refactoring and getMacAddr needs to be moved to a utility class + char ourId[5]; + + /// Initializes the UI, turns on the display, starts showing boot screen. + // + // Not thread safe - must be called before any other methods are called. + void setup(); + + /// Turns the screen on/off. Optionally, pass a custom screensaver frame for E-Ink + void setOn(bool on, FrameCallback einkScreensaver = NULL); + /** + * Prepare the display for the unit going to the lowest power mode possible. Most screens will just + * poweroff, but eink screens will show a "I'm sleeping" graphic, possibly with a QR code + */ + void doDeepSleep(); + + void blink(); + + // Draw north + float estimatedHeading(double lat, double lon); + + /// Handle button press, trackball or swipe action) + void onPress() { enqueueCmd(ScreenCmd{.cmd = Cmd::ON_PRESS}); } + void showPrevFrame() { enqueueCmd(ScreenCmd{.cmd = Cmd::SHOW_PREV_FRAME}); } + void showNextFrame() { enqueueCmd(ScreenCmd{.cmd = Cmd::SHOW_NEXT_FRAME}); } + void showFrame(FrameDirection direction); + + // generic alert start + void startAlert(FrameCallback _alertFrame) + { + alertFrame = _alertFrame; + ScreenCmd cmd; + cmd.cmd = Cmd::START_ALERT_FRAME; + enqueueCmd(cmd); + } + + void startAlert(const char *_alertMessage) + { + startAlert([_alertMessage](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { + uint16_t x_offset = display->width() / 2; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_MEDIUM); + display->drawString(x_offset + x, 26 + y, _alertMessage); + }); + } + + void endAlert() + { + ScreenCmd cmd; + cmd.cmd = Cmd::STOP_ALERT_FRAME; + enqueueCmd(cmd); + } + + void showSimpleBanner(const char *message, uint32_t durationMs = 0); + void showOverlayBanner(BannerOverlayOptions); + + void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback); + void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function bannerCallback); + void showTextInput(const char *header, const char *initialText, uint32_t durationMs, + std::function textCallback); + + void requestMenu(graphics::menuHandler::screenMenus menuToShow) + { + graphics::menuHandler::menuQueue = menuToShow; + runNow(); + } + + void startFirmwareUpdateScreen() + { + ScreenCmd cmd; + cmd.cmd = Cmd::START_FIRMWARE_UPDATE_SCREEN; + enqueueCmd(cmd); + } + + // Function to allow the AccelerometerThread to set the heading if a sensor provides it + // Mutex needed? + void setHeading(long _heading) + { + hasCompass = true; + compassHeading = fmod(_heading, 360); + } + + bool hasHeading() { return hasCompass; } + + long getHeading() { return compassHeading; } + + void setEndCalibration(uint32_t _endCalibrationAt) { endCalibrationAt = _endCalibrationAt; } + uint32_t getEndCalibration() { return endCalibrationAt; } + + // functions for display brightness + void increaseBrightness(); + void decreaseBrightness(); + + /// Stops showing the boot screen. + void stopBootScreen() { enqueueCmd(ScreenCmd{.cmd = Cmd::STOP_BOOT_SCREEN}); } + + void runNow() + { + setFastFramerate(); + enqueueCmd(ScreenCmd{.cmd = Cmd::NOOP}); + } + + /// Overrides the default utf8 character conversion, to replace empty space with question marks + static char customFontTableLookup(const uint8_t ch) + { + // UTF-8 to font table index converter + // Code from http://playground.arduino.cc/Main/Utf8ascii + static uint8_t LASTCHAR; + static bool SKIPREST; // Only display a single unconvertable-character symbol per sequence of unconvertable characters + + if (ch < 128) { // Standard ASCII-set 0..0x7F handling + LASTCHAR = 0; + SKIPREST = false; + return ch; + } + + uint8_t last = LASTCHAR; // get last char + LASTCHAR = ch; + + switch (last) { + case 0xC2: { + SKIPREST = false; + return (uint8_t)ch; + } + + case 0xC3: { + SKIPREST = false; + return (uint8_t)(ch | 0xC0); + } + } + + // We want to strip out prefix chars for two-byte char formats + if (ch == 0xC2 || ch == 0xC3) + return (uint8_t)0; + +#if defined(OLED_PL) + + switch (last) { + case 0xC3: { + + if (ch == 147) + return (uint8_t)(ch); // Ó + else if (ch == 179) + return (uint8_t)(148); // ó + else + return (uint8_t)(ch | 0xC0); + break; + } + + case 0xC4: { + SKIPREST = false; + return (uint8_t)(ch); + } + + case 0xC5: { + SKIPREST = false; + if (ch == 132) + return (uint8_t)(136); // ń + else if (ch == 186) + return (uint8_t)(137); // ź + else + return (uint8_t)(ch); + break; + } + } + + // We want to strip out prefix chars for two-byte char formats + if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5) + return (uint8_t)0; + +#endif + +#if defined(OLED_UA) || defined(OLED_RU) + + switch (last) { + case 0xC3: { + SKIPREST = false; + return (uint8_t)(ch | 0xC0); + } + // map UTF-8 cyrillic chars to it Windows-1251 (CP-1251) ASCII codes + // note: in this case we must use compatible font - provided ArialMT_Plain_10/16/24 by 'ThingPulse/esp8266-oled-ssd1306' + // library have empty chars for non-latin ASCII symbols + case 0xD0: { + SKIPREST = false; + if (ch == 132) + return (uint8_t)(170); // Є + if (ch == 134) + return (uint8_t)(178); // І + if (ch == 135) + return (uint8_t)(175); // Ї + if (ch == 129) + return (uint8_t)(168); // Ё + if (ch > 143 && ch < 192) + return (uint8_t)(ch + 48); + break; + } + case 0xD1: { + SKIPREST = false; + if (ch == 148) + return (uint8_t)(186); // є + if (ch == 150) + return (uint8_t)(179); // і + if (ch == 151) + return (uint8_t)(191); // ї + if (ch == 145) + return (uint8_t)(184); // ё + if (ch > 127 && ch < 144) + return (uint8_t)(ch + 112); + break; + } + case 0xD2: { + SKIPREST = false; + if (ch == 144) + return (uint8_t)(165); // Ґ + if (ch == 145) + return (uint8_t)(180); // ґ + break; + } + } + + // We want to strip out prefix chars for two-byte char formats + if (ch == 0xC2 || ch == 0xC3 || ch == 0x82 || ch == 0xD0 || ch == 0xD1) + return (uint8_t)0; + +#endif + +#if defined(OLED_CS) + + switch (last) { + case 0xC2: { + SKIPREST = false; + return (uint8_t)ch; + } + + case 0xC3: { + SKIPREST = false; + return (uint8_t)(ch | 0xC0); + } + + case 0xC4: { + SKIPREST = false; + if (ch == 140) + return (uint8_t)(129); // Č + if (ch == 141) + return (uint8_t)(138); // č + if (ch == 142) + return (uint8_t)(130); // Ď + if (ch == 143) + return (uint8_t)(139); // ď + if (ch == 154) + return (uint8_t)(131); // Ě + if (ch == 155) + return (uint8_t)(140); // ě + // Slovak specific glyphs + if (ch == 185) + return (uint8_t)(147); // Ĺ + if (ch == 186) + return (uint8_t)(148); // ĺ + if (ch == 189) + return (uint8_t)(149); // Ľ + if (ch == 190) + return (uint8_t)(150); // ľ + break; + } + + case 0xC5: { + SKIPREST = false; + if (ch == 135) + return (uint8_t)(132); // Ň + if (ch == 136) + return (uint8_t)(141); // ň + if (ch == 152) + return (uint8_t)(133); // Ř + if (ch == 153) + return (uint8_t)(142); // ř + if (ch == 160) + return (uint8_t)(134); // Š + if (ch == 161) + return (uint8_t)(143); // š + if (ch == 164) + return (uint8_t)(135); // Ť + if (ch == 165) + return (uint8_t)(144); // ť + if (ch == 174) + return (uint8_t)(136); // Ů + if (ch == 175) + return (uint8_t)(145); // ů + if (ch == 189) + return (uint8_t)(137); // Ž + if (ch == 190) + return (uint8_t)(146); // ž + // Slovak specific glyphs + if (ch == 148) + return (uint8_t)(151); // Ŕ + if (ch == 149) + return (uint8_t)(152); // ŕ + break; + } + } + + // We want to strip out prefix chars for two-byte char formats + if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5) + return (uint8_t)0; + +#endif + + // If we already returned an unconvertable-character symbol for this unconvertable-character sequence, return NULs for the + // rest of it + if (SKIPREST) + return (uint8_t)0; + SKIPREST = true; + + return (uint8_t)191; // otherwise: return ¿ if character can't be converted (note that the font map we're using doesn't + // stick to standard EASCII codes) + } + + /// Returns a handle to the DebugInfo screen. + // + // Use this handle to set things like battery status, user count, GPS status, etc. + DebugInfo *debug_info() { return &debugInfo; } + + // Handle observer events + int handleStatusUpdate(const meshtastic::Status *arg); + int handleTextMessage(const meshtastic_MeshPacket *packet); + int handleUIFrameEvent(const UIFrameEvent *arg); + int handleInputEvent(const InputEvent *arg); + int handleAdminMessage(AdminModule_ObserverData *arg); + + /// Used to force (super slow) eink displays to draw critical frames + void forceDisplay(bool forceUiUpdate = false); + + /// Draws our SSL cert screen during boot (called from WebServer) + void setSSLFrames(); + + // Menu-driven Show / Hide Toggle + void toggleFrameVisibility(const std::string &frameName); + bool isFrameHidden(const std::string &frameName) const; + +#ifdef USE_EINK + /// Draw an image to remain on E-Ink display after screen off + void setScreensaverFrames(FrameCallback einkScreensaver = NULL); +#endif + + protected: + /// Updates the UI. + // + // Called periodically from the main loop. + int32_t runOnce() final; + + bool isAUTOOled = false; + + // Screen dimensions (for convenience) + // Defined during Screen::setup + uint16_t displayWidth = 0; + uint16_t displayHeight = 0; + + private: + FrameCallback alertFrames[1]; + struct ScreenCmd { + Cmd cmd; + union { + uint32_t bluetooth_pin; + char *print_text; + }; + }; + + /// Enques given command item to be processed by main loop(). + bool enqueueCmd(const ScreenCmd &cmd) + { + if (!useDisplay) + return false; // not enqueued if our display is not in use + else { + bool success = cmdQueue.enqueue(cmd, 0); + enabled = true; // handle ASAP (we are the registered reader for cmdQueue, but might have been disabled) + return success; + } + } + + // Implementations of various commands, called from doTask(). + void handleSetOn(bool on, FrameCallback einkScreensaver = NULL); + void handleOnPress(); + void handleStartFirmwareUpdateScreen(); + + // Info collected by setFrames method. + // Index location of specific frames. + // - Used to apply the FrameFocus parameter of setFrames + // - Used to dismiss the currently shown frame (txt; waypoint) by CardKB combo + struct FramesetInfo { + struct FramePositions { + uint8_t fault = 255; + uint8_t waypoint = 255; + uint8_t focusedModule = 255; + uint8_t log = 255; + uint8_t settings = 255; + uint8_t wifi = 255; + uint8_t deviceFocused = 255; + uint8_t system = 255; + uint8_t gps = 255; + uint8_t home = 255; + uint8_t textMessage = 255; + uint8_t nodelist_nodes = 255; + uint8_t nodelist_location = 255; + uint8_t nodelist_lastheard = 255; + uint8_t nodelist_hopsignal = 255; + uint8_t nodelist_distance = 255; + uint8_t nodelist_bearings = 255; + uint8_t clock = 255; + uint8_t chirpy = 255; + uint8_t firstFavorite = 255; + uint8_t lastFavorite = 255; + uint8_t lora = 255; + } positions; + + uint8_t frameCount = 0; + } framesetInfo; + + struct hiddenFrames { + bool textMessage = false; + bool waypoint = false; + bool wifi = false; + bool system = false; + bool home = false; + bool clock = false; +#ifndef USE_EINK + bool nodelist_nodes = false; + bool nodelist_location = false; +#endif +#ifdef USE_EINK + bool nodelist_lastheard = false; + bool nodelist_hopsignal = false; + bool nodelist_distance = false; +#endif +#if HAS_GPS +#ifdef USE_EINK + bool nodelist_bearings = false; +#endif + bool gps = false; +#endif + bool lora = false; + bool show_favorites = false; + bool chirpy = true; + } hiddenFrames; + + /// Try to start drawing ASAP + void setFastFramerate(); + + // Sets frame up for immediate drawing + void setFrameImmediateDraw(FrameCallback *drawFrames); + + /// callback for current alert frame + FrameCallback alertFrame; + + /// Queue of commands to execute in doTask. + TypedQueue cmdQueue; + /// Whether we are using a display + bool useDisplay = false; + /// Whether the display is currently powered + bool screenOn = false; + // Whether we are showing the regular screen (as opposed to booth screen or + // Bluetooth PIN screen) + bool showingNormalScreen = false; + + // Implementation to Adjust Brightness + uint8_t brightness = BRIGHTNESS_DEFAULT; // H = 254, MH = 192, ML = 130 L = 103 + + bool hasCompass = false; + float compassHeading; + uint32_t endCalibrationAt; + + /// Holds state for debug information + DebugInfo debugInfo; + + /// Display device + OLEDDisplay *dispdev; + + /// UI helper for rendering to frames and switching between them + OLEDDisplayUi *ui; +}; + +} // namespace graphics + +// Extern declarations for function symbols used in UIRenderer +extern std::vector functionSymbol; +extern std::string functionSymbolString; +extern graphics::Screen *screen; + +#endif \ No newline at end of file From e52ef916e198e2512b282e152f3de59c6113f2c2 Mon Sep 17 00:00:00 2001 From: Jason P Date: Thu, 11 Dec 2025 14:15:27 -0600 Subject: [PATCH 2/2] Move files into the right place --- MenuHandler.cpp | 2205 ------------------------ NodeListRenderer.cpp | 698 -------- NodeListRenderer.h | 67 - Screen.cpp | 1804 ------------------- Screen.h | 743 -------- src/graphics/Screen.cpp | 33 +- src/graphics/Screen.h | 8 +- src/graphics/draw/MenuHandler.cpp | 78 +- src/graphics/draw/NodeListRenderer.cpp | 106 +- src/graphics/draw/NodeListRenderer.h | 15 +- 10 files changed, 172 insertions(+), 5585 deletions(-) delete mode 100644 MenuHandler.cpp delete mode 100644 NodeListRenderer.cpp delete mode 100644 NodeListRenderer.h delete mode 100644 Screen.cpp delete mode 100644 Screen.h diff --git a/MenuHandler.cpp b/MenuHandler.cpp deleted file mode 100644 index f07d29a667c..00000000000 --- a/MenuHandler.cpp +++ /dev/null @@ -1,2205 +0,0 @@ -#include "configuration.h" -#if HAS_SCREEN -#include "ClockRenderer.h" -#include "Default.h" -#include "GPS.h" -#include "MenuHandler.h" -#include "MeshRadio.h" -#include "MeshService.h" -#include "MessageStore.h" -#include "NodeDB.h" -#include "buzz.h" -#include "graphics/Screen.h" -#include "graphics/SharedUIDisplay.h" -#include "graphics/draw/MessageRenderer.h" -#include "graphics/draw/UIRenderer.h" -#include "input/RotaryEncoderInterruptImpl1.h" -#include "input/UpDownInterruptImpl1.h" -#include "main.h" -#include "mesh/Default.h" -#include "mesh/MeshTypes.h" -#include "modules/AdminModule.h" -#include "modules/CannedMessageModule.h" -#include "modules/KeyVerificationModule.h" - -#include "modules/TraceRouteModule.h" -#include - -extern uint16_t TFT_MESH; - -namespace graphics -{ -menuHandler::screenMenus menuHandler::menuQueue = menu_none; -bool test_enabled = false; -uint8_t test_count = 0; - -void menuHandler::loraMenu() -{ - static const char *optionsArray[] = {"Back", "Device Role", "Radio Preset", "LoRa Region"}; - enum optionsNumbers { Back = 0, device_role_picker = 1, radio_preset_picker = 2, lora_picker = 3 }; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "LoRa Actions"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 4; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Back) { - // No action - } else if (selected == device_role_picker) { - menuHandler::menuQueue = menuHandler::device_role_picker; - } else if (selected == radio_preset_picker) { - menuHandler::menuQueue = menuHandler::radio_preset_picker; - } else if (selected == lora_picker) { - menuHandler::menuQueue = menuHandler::lora_picker; - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::OnboardMessage() -{ - static const char *optionsArray[] = {"OK", "Got it!"}; - enum optionsNumbers { OK, got }; - BannerOverlayOptions bannerOptions; -#if HAS_TFT - bannerOptions.message = "Welcome to Meshtastic!\nSwipe to navigate and\nlong press to select\nor open a menu."; -#elif defined(BUTTON_PIN) - bannerOptions.message = "Welcome to Meshtastic!\nClick to navigate and\nlong press to select\nor open a menu."; -#else - bannerOptions.message = "Welcome to Meshtastic!\nUse the Select button\nto open menus\nand make selections."; -#endif - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 2; - bannerOptions.bannerCallback = [](int selected) -> void { - menuHandler::menuQueue = menuHandler::no_timeout_lora_picker; - screen->runNow(); - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::LoraRegionPicker(uint32_t duration) -{ - static const char *optionsArray[] = {"Back", - "US", - "EU_433", - "EU_868", - "CN", - "JP", - "ANZ", - "KR", - "TW", - "RU", - "IN", - "NZ_865", - "TH", - "LORA_24", - "UA_433", - "UA_868", - "MY_433", - "MY_" - "919", - "SG_" - "923", - "PH_433", - "PH_868", - "PH_915", - "ANZ_433", - "KZ_433", - "KZ_863", - "NP_865", - "BR_902"}; - BannerOverlayOptions bannerOptions; -#if defined(M5STACK_UNITC6L) - bannerOptions.message = "LoRa Region"; -#else - bannerOptions.message = "Set the LoRa region"; -#endif - bannerOptions.durationMs = duration; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 27; - bannerOptions.InitialSelected = 0; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected != 0 && config.lora.region != _meshtastic_Config_LoRaConfig_RegionCode(selected)) { - config.lora.region = _meshtastic_Config_LoRaConfig_RegionCode(selected); - auto changes = SEGMENT_CONFIG; - - // This is needed as we wait til picking the LoRa region to generate keys for the first time. -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - if (config.security.private_key.size == 32) { - // public key is derived from private, so this will always have the same result. - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - - } else { - LOG_INFO("Generate new PKI keys"); - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } - } -#endif - config.lora.tx_enabled = true; - initRegion(); - if (myRegion->dutyCycle < 100) { - config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit - } - - if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { - // Default broker is in use, so subscribe to the appropriate MQTT root topic for this region - sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); - changes |= SEGMENT_MODULECONFIG; - } - - service->reloadConfig(changes); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::DeviceRolePicker() -{ - static const char *optionsArray[] = {"Back", "Client", "Client Mute", "Lost and Found", "Tracker"}; - enum optionsNumbers { - Back = 0, - devicerole_client = 1, - devicerole_clientmute = 2, - devicerole_lostandfound = 3, - devicerole_tracker = 4 - }; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Device Role"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 5; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Back) { - menuHandler::menuQueue = menuHandler::lora_Menu; - screen->runNow(); - return; - } else if (selected == devicerole_client) { - config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; - } else if (selected == devicerole_clientmute) { - config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE; - } else if (selected == devicerole_lostandfound) { - config.device.role = meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND; - } else if (selected == devicerole_tracker) { - config.device.role = meshtastic_Config_DeviceConfig_Role_TRACKER; - } - service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::RadioPresetPicker() -{ - static const char *optionsArray[] = {"Back", "LongSlow", "LongModerate", "LongFast", "MediumSlow", - "MediumFast", "ShortSlow", "ShortFast", "ShortTurbo"}; - enum optionsNumbers { - Back = 0, - radiopreset_LongSlow = 1, - radiopreset_LongModerate = 2, - radiopreset_LongFast = 3, - radiopreset_MediumSlow = 4, - radiopreset_MediumFast = 5, - radiopreset_ShortSlow = 6, - radiopreset_ShortFast = 7, - radiopreset_ShortTurbo = 8 - }; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Radio Preset"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 9; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Back) { - menuHandler::menuQueue = menuHandler::lora_Menu; - screen->runNow(); - return; - } else if (selected == radiopreset_LongSlow) { - config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; - } else if (selected == radiopreset_LongModerate) { - config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE; - } else if (selected == radiopreset_LongFast) { - config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; - } else if (selected == radiopreset_MediumSlow) { - config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW; - } else if (selected == radiopreset_MediumFast) { - config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - } else if (selected == radiopreset_ShortSlow) { - config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW; - } else if (selected == radiopreset_ShortFast) { - config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; - } else if (selected == radiopreset_ShortTurbo) { - config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; - } - service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::TwelveHourPicker() -{ - static const char *optionsArray[] = {"Back", "12-hour", "24-hour"}; - enum optionsNumbers { Back = 0, twelve = 1, twentyfour = 2 }; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Time Format"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 3; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Back) { - menuHandler::menuQueue = menuHandler::clock_menu; - screen->runNow(); - } else if (selected == twelve) { - config.display.use_12h_clock = true; - } else { - config.display.use_12h_clock = false; - } - service->reloadConfig(SEGMENT_CONFIG); - }; - screen->showOverlayBanner(bannerOptions); -} - -// Reusable confirmation prompt function -void menuHandler::showConfirmationBanner(const char *message, std::function onConfirm) -{ - static const char *confirmOptions[] = {"No", "Yes"}; - BannerOverlayOptions confirmBanner; - confirmBanner.message = message; - confirmBanner.optionsArrayPtr = confirmOptions; - confirmBanner.optionsCount = 2; - confirmBanner.bannerCallback = [onConfirm](int confirmSelected) -> void { - if (confirmSelected == 1) { - onConfirm(); - } - }; - screen->showOverlayBanner(confirmBanner); -} - -void menuHandler::ClockFacePicker() -{ - static const char *optionsArray[] = {"Back", "Digital", "Analog"}; - enum optionsNumbers { Back = 0, Digital = 1, Analog = 2 }; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Which Face?"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 3; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Back) { - menuHandler::menuQueue = menuHandler::clock_menu; - screen->runNow(); - } else if (selected == Digital) { - uiconfig.is_clockface_analog = false; - saveUIConfig(); - screen->setFrames(Screen::FOCUS_CLOCK); - } else { - uiconfig.is_clockface_analog = true; - saveUIConfig(); - screen->setFrames(Screen::FOCUS_CLOCK); - } - }; - bannerOptions.InitialSelected = uiconfig.is_clockface_analog ? 2 : 1; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::TZPicker() -{ - static const char *optionsArray[] = {"Back", - "US/Hawaii", - "US/Alaska", - "US/Pacific", - "US/Arizona", - "US/Mountain", - "US/Central", - "US/Eastern", - "BR/Brasilia", - "UTC", - "EU/Western", - "EU/" - "Central", - "EU/Eastern", - "Asia/Kolkata", - "Asia/Hong_Kong", - "AU/AWST", - "AU/ACST", - "AU/AEST", - "Pacific/NZ"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Pick Timezone"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 19; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 0) { - menuHandler::menuQueue = menuHandler::clock_menu; - screen->runNow(); - } else if (selected == 1) { // Hawaii - strncpy(config.device.tzdef, "HST10", sizeof(config.device.tzdef)); - } else if (selected == 2) { // Alaska - strncpy(config.device.tzdef, "AKST9AKDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); - } else if (selected == 3) { // Pacific - strncpy(config.device.tzdef, "PST8PDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); - } else if (selected == 4) { // Arizona - strncpy(config.device.tzdef, "MST7", sizeof(config.device.tzdef)); - } else if (selected == 5) { // Mountain - strncpy(config.device.tzdef, "MST7MDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); - } else if (selected == 6) { // Central - strncpy(config.device.tzdef, "CST6CDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); - } else if (selected == 7) { // Eastern - strncpy(config.device.tzdef, "EST5EDT,M3.2.0,M11.1.0", sizeof(config.device.tzdef)); - } else if (selected == 8) { // Brazil - strncpy(config.device.tzdef, "BRT3", sizeof(config.device.tzdef)); - } else if (selected == 9) { // UTC - strncpy(config.device.tzdef, "UTC0", sizeof(config.device.tzdef)); - } else if (selected == 10) { // EU/Western - strncpy(config.device.tzdef, "GMT0BST,M3.5.0/1,M10.5.0", sizeof(config.device.tzdef)); - } else if (selected == 11) { // EU/Central - strncpy(config.device.tzdef, "CET-1CEST,M3.5.0,M10.5.0/3", sizeof(config.device.tzdef)); - } else if (selected == 12) { // EU/Eastern - strncpy(config.device.tzdef, "EET-2EEST,M3.5.0/3,M10.5.0/4", sizeof(config.device.tzdef)); - } else if (selected == 13) { // Asia/Kolkata - strncpy(config.device.tzdef, "IST-5:30", sizeof(config.device.tzdef)); - } else if (selected == 14) { // China - strncpy(config.device.tzdef, "HKT-8", sizeof(config.device.tzdef)); - } else if (selected == 15) { // AU/AWST - strncpy(config.device.tzdef, "AWST-8", sizeof(config.device.tzdef)); - } else if (selected == 16) { // AU/ACST - strncpy(config.device.tzdef, "ACST-9:30ACDT,M10.1.0,M4.1.0/3", sizeof(config.device.tzdef)); - } else if (selected == 17) { // AU/AEST - strncpy(config.device.tzdef, "AEST-10AEDT,M10.1.0,M4.1.0/3", sizeof(config.device.tzdef)); - } else if (selected == 18) { // NZ - strncpy(config.device.tzdef, "NZST-12NZDT,M9.5.0,M4.1.0/3", sizeof(config.device.tzdef)); - } - if (selected != 0) { - setenv("TZ", config.device.tzdef, 1); - service->reloadConfig(SEGMENT_CONFIG); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::clockMenu() -{ -#if defined(M5STACK_UNITC6L) - static const char *optionsArray[] = {"Back", "Time Format", "Timezone"}; -#else - static const char *optionsArray[] = {"Back", "Clock Face", "Time Format", "Timezone"}; -#endif - enum optionsNumbers { Back = 0, Clock = 1, Time = 2, Timezone = 3 }; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Clock Action"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 4; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Clock) { - menuHandler::menuQueue = menuHandler::clock_face_picker; - screen->runNow(); - } else if (selected == Time) { - menuHandler::menuQueue = menuHandler::twelve_hour_picker; - screen->runNow(); - } else if (selected == Timezone) { - menuHandler::menuQueue = menuHandler::TZ_picker; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} -void menuHandler::messageResponseMenu() -{ - enum optionsNumbers { Back = 0, ViewMode, DeleteAll, DeleteOldest, ReplyMenu, Aloud, enumEnd }; - - static const char *optionsArray[enumEnd]; - static int optionsEnumArray[enumEnd]; - int options = 0; - - auto mode = graphics::MessageRenderer::getThreadMode(); - - optionsArray[options] = "Back"; - optionsEnumArray[options++] = Back; - - // New Reply submenu (replaces Preset and Freetext directly in this menu) - optionsArray[options] = "Reply"; - optionsEnumArray[options++] = ReplyMenu; - - optionsArray[options] = "View Chats"; - optionsEnumArray[options++] = ViewMode; - - // Delete submenu - optionsArray[options] = "Delete"; - optionsEnumArray[options++] = 900; - -#ifdef HAS_I2S - optionsArray[options] = "Read Aloud"; - optionsEnumArray[options++] = Aloud; -#endif - - BannerOverlayOptions bannerOptions; -#if defined(M5STACK_UNITC6L) - bannerOptions.message = "Message"; -#else - bannerOptions.message = "Message Action"; -#endif - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.optionsCount = options; - bannerOptions.bannerCallback = [](int selected) -> void { - LOG_DEBUG("messageResponseMenu: selected %d", selected); - - auto mode = graphics::MessageRenderer::getThreadMode(); - int ch = graphics::MessageRenderer::getThreadChannel(); - uint32_t peer = graphics::MessageRenderer::getThreadPeer(); - - LOG_DEBUG("[ReplyCtx] mode=%d ch=%d peer=0x%08x", (int)mode, ch, (unsigned int)peer); - - if (selected == ViewMode) { - menuHandler::menuQueue = menuHandler::message_viewmode_menu; - screen->runNow(); - - // Reply submenu - } else if (selected == ReplyMenu) { - menuHandler::menuQueue = menuHandler::reply_menu; - screen->runNow(); - - // Delete submenu - } else if (selected == 900) { - menuHandler::menuQueue = menuHandler::delete_messages_menu; - screen->runNow(); - - // Delete oldest FIRST (only change) - } else if (selected == DeleteOldest) { - auto mode = graphics::MessageRenderer::getThreadMode(); - int ch = graphics::MessageRenderer::getThreadChannel(); - uint32_t peer = graphics::MessageRenderer::getThreadPeer(); - - if (mode == graphics::MessageRenderer::ThreadMode::ALL) { - // Global oldest - messageStore.deleteOldestMessage(); - } else if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { - // Oldest in current channel - messageStore.deleteOldestMessageInChannel(ch); - } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { - // Oldest in current DM - messageStore.deleteOldestMessageWithPeer(peer); - } - - // Delete all messages - } else if (selected == DeleteAll) { - messageStore.clearAllMessages(); - graphics::MessageRenderer::clearThreadRegistries(); - graphics::MessageRenderer::clearMessageCache(); - -#ifdef HAS_I2S - } else if (selected == Aloud) { - const meshtastic_MeshPacket &mp = devicestate.rx_text_message; - const char *msg = reinterpret_cast(mp.decoded.payload.bytes); - audioThread->readAloud(msg); -#endif - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::replyMenu() -{ - enum replyOptions { Back = 0, ReplyPreset, ReplyFreetext, enumEnd }; - - static const char *optionsArray[enumEnd]; - static int optionsEnumArray[enumEnd]; - int options = 0; - - // Back - optionsArray[options] = "Back"; - optionsEnumArray[options++] = Back; - - // Preset reply - optionsArray[options] = "With Preset"; - optionsEnumArray[options++] = ReplyPreset; - - // Freetext reply (only when keyboard exists) - if (kb_found) { - optionsArray[options] = "With Freetext"; - optionsEnumArray[options++] = ReplyFreetext; - } - - BannerOverlayOptions bannerOptions; - - // Dynamic title based on thread mode - auto mode = graphics::MessageRenderer::getThreadMode(); - if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { - bannerOptions.message = "Reply to Channel"; - } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { - bannerOptions.message = "Reply to DM"; - } else { - // View All - bannerOptions.message = "Reply to Last Msg"; - } - - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.optionsCount = options; - bannerOptions.InitialSelected = 1; - - bannerOptions.bannerCallback = [](int selected) -> void { - auto mode = graphics::MessageRenderer::getThreadMode(); - int ch = graphics::MessageRenderer::getThreadChannel(); - uint32_t peer = graphics::MessageRenderer::getThreadPeer(); - - if (selected == Back) { - menuHandler::menuQueue = menuHandler::message_response_menu; - screen->runNow(); - return; - } - - // Preset reply - if (selected == ReplyPreset) { - - if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { - cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, ch); - - } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { - cannedMessageModule->LaunchWithDestination(peer); - - } else { - // Fallback for last received message - if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { - cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel); - } else { - cannedMessageModule->LaunchWithDestination(devicestate.rx_text_message.from); - } - } - - return; - } - - // Freetext reply - if (selected == ReplyFreetext) { - - if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { - cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, ch); - - } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { - cannedMessageModule->LaunchFreetextWithDestination(peer); - - } else { - // Fallback for last received message - if (devicestate.rx_text_message.to == NODENUM_BROADCAST) { - cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST, devicestate.rx_text_message.channel); - } else { - cannedMessageModule->LaunchFreetextWithDestination(devicestate.rx_text_message.from); - } - } - - return; - } - }; - screen->showOverlayBanner(bannerOptions); -} -void menuHandler::deleteMessagesMenu() -{ - enum optionsNumbers { Back = 0, DeleteOldest, DeleteThis, DeleteAll, enumEnd }; - - static const char *optionsArray[enumEnd]; - static int optionsEnumArray[enumEnd]; - int options = 0; - - auto mode = graphics::MessageRenderer::getThreadMode(); - - optionsArray[options] = "Back"; - optionsEnumArray[options++] = Back; - - optionsArray[options] = "Delete Oldest"; - optionsEnumArray[options++] = DeleteOldest; - - // If viewing ALL chats → hide “Delete This Chat” - if (mode != graphics::MessageRenderer::ThreadMode::ALL) { - optionsArray[options] = "Delete This Chat"; - optionsEnumArray[options++] = DeleteThis; - } -#if defined(M5STACK_UNITC6L) - optionsArray[options] = "Delete All"; -#else - optionsArray[options] = "Delete All Chats"; -#endif - optionsEnumArray[options++] = DeleteAll; - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Delete Messages"; - - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.optionsCount = options; - bannerOptions.bannerCallback = [mode](int selected) -> void { - int ch = graphics::MessageRenderer::getThreadChannel(); - uint32_t peer = graphics::MessageRenderer::getThreadPeer(); - - if (selected == Back) { - menuHandler::menuQueue = menuHandler::message_response_menu; - screen->runNow(); - return; - } - - if (selected == DeleteAll) { - LOG_INFO("Deleting all messages"); - messageStore.clearAllMessages(); - graphics::MessageRenderer::clearThreadRegistries(); - graphics::MessageRenderer::clearMessageCache(); - return; - } - - if (selected == DeleteOldest) { - LOG_INFO("Deleting oldest message"); - - if (mode == graphics::MessageRenderer::ThreadMode::ALL) { - messageStore.deleteOldestMessage(); - } else if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { - messageStore.deleteOldestMessageInChannel(ch); - } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { - messageStore.deleteOldestMessageWithPeer(peer); - } - - return; - } - - // This only appears in non-ALL modes - if (selected == DeleteThis) { - LOG_INFO("Deleting all messages in this thread"); - - if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { - messageStore.deleteAllMessagesInChannel(ch); - } else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { - messageStore.deleteAllMessagesWithPeer(peer); - } - - return; - } - }; - - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::messageViewModeMenu() -{ - auto encodeChannelId = [](int ch) -> int { return 100 + ch; }; - auto isChannelSel = [](int id) -> bool { return id >= 100 && id < 200; }; - - static std::vector labels; - static std::vector ids; - static std::vector idToPeer; // DM lookup - - labels.clear(); - ids.clear(); - idToPeer.clear(); - - labels.push_back("Back"); - ids.push_back(-1); - labels.push_back("View All Chats"); - ids.push_back(-2); - - // Channels with messages - for (int ch = 0; ch < 8; ++ch) { - auto msgs = messageStore.getChannelMessages((uint8_t)ch); - if (!msgs.empty()) { - char buf[40]; - const char *cname = channels.getName(ch); - snprintf(buf, sizeof(buf), cname && cname[0] ? "#%s" : "#Ch%d", cname ? cname : "", ch); - labels.push_back(buf); - ids.push_back(encodeChannelId(ch)); - LOG_DEBUG("messageViewModeMenu: Added live channel %s (id=%d)", buf, encodeChannelId(ch)); - } - } - - // Registry channels - for (int ch : graphics::MessageRenderer::getSeenChannels()) { - if (ch < 0 || ch >= 8) - continue; - auto msgs = messageStore.getChannelMessages((uint8_t)ch); - if (msgs.empty()) - continue; - int enc = encodeChannelId(ch); - if (std::find(ids.begin(), ids.end(), enc) == ids.end()) { - char buf[40]; - const char *cname = channels.getName(ch); - snprintf(buf, sizeof(buf), cname && cname[0] ? "#%s" : "#Ch%d", cname ? cname : "", ch); - labels.push_back(buf); - ids.push_back(enc); - LOG_DEBUG("messageViewModeMenu: Added registry channel %s (id=%d)", buf, enc); - } - } - - // Gather unique peers - auto dms = messageStore.getDirectMessages(); - std::vector uniquePeers; - for (auto &m : dms) { - uint32_t peer = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender; - if (peer != nodeDB->getNodeNum() && std::find(uniquePeers.begin(), uniquePeers.end(), peer) == uniquePeers.end()) - uniquePeers.push_back(peer); - } - for (uint32_t peer : graphics::MessageRenderer::getSeenPeers()) { - if (peer != nodeDB->getNodeNum() && std::find(uniquePeers.begin(), uniquePeers.end(), peer) == uniquePeers.end()) - uniquePeers.push_back(peer); - } - std::sort(uniquePeers.begin(), uniquePeers.end()); - - // Encode peers - for (size_t i = 0; i < uniquePeers.size(); ++i) { - uint32_t peer = uniquePeers[i]; - auto node = nodeDB->getMeshNode(peer); - std::string name; - if (node && node->has_user) - name = sanitizeString(node->user.long_name).substr(0, 15); - else { - char buf[20]; - snprintf(buf, sizeof(buf), "Node %08X", peer); - name = buf; - } - labels.push_back("@" + name); - int encPeer = 1000 + (int)idToPeer.size(); - ids.push_back(encPeer); - idToPeer.push_back(peer); - LOG_DEBUG("messageViewModeMenu: Added DM %s peer=0x%08x id=%d", name.c_str(), (unsigned int)peer, encPeer); - } - - // Active ID - int activeId = -2; - auto mode = graphics::MessageRenderer::getThreadMode(); - if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) - activeId = encodeChannelId(graphics::MessageRenderer::getThreadChannel()); - else if (mode == graphics::MessageRenderer::ThreadMode::DIRECT) { - uint32_t cur = graphics::MessageRenderer::getThreadPeer(); - for (size_t i = 0; i < idToPeer.size(); ++i) - if (idToPeer[i] == cur) { - activeId = 1000 + (int)i; - break; - } - } - - LOG_DEBUG("messageViewModeMenu: Active thread id=%d", activeId); - - // Build banner - static std::vector options; - static std::vector optionIds; - options.clear(); - optionIds.clear(); - - int initialIndex = 0; - for (size_t i = 0; i < labels.size(); i++) { - options.push_back(labels[i].c_str()); - optionIds.push_back(ids[i]); - if (ids[i] == activeId) - initialIndex = (int)i; - } - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Select Conversation"; - bannerOptions.optionsArrayPtr = options.data(); - bannerOptions.optionsEnumPtr = optionIds.data(); - bannerOptions.optionsCount = options.size(); - bannerOptions.InitialSelected = initialIndex; - - bannerOptions.bannerCallback = [=](int selected) -> void { - LOG_DEBUG("messageViewModeMenu: selected=%d", selected); - if (selected == -1) { - menuHandler::menuQueue = menuHandler::message_response_menu; - screen->runNow(); - } else if (selected == -2) { - graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::ALL); - } else if (isChannelSel(selected)) { - int ch = selected - 100; - graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::CHANNEL, ch); - } else if (selected >= 1000) { - int idx = selected - 1000; - if (idx >= 0 && (size_t)idx < idToPeer.size()) { - uint32_t peer = idToPeer[idx]; - graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, peer); - } - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::homeBaseMenu() -{ - enum optionsNumbers { Back, Backlight, Position, Preset, Freetext, Sleep, enumEnd }; - - static const char *optionsArray[enumEnd] = {"Back"}; - static int optionsEnumArray[enumEnd] = {Back}; - int options = 1; - -#if defined(PIN_EINK_EN) || defined(PCA_PIN_EINK_EN) - optionsArray[options] = "Toggle Backlight"; - optionsEnumArray[options++] = Backlight; -#else - optionsArray[options] = "Sleep Screen"; - optionsEnumArray[options++] = Sleep; -#endif - if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - optionsArray[options] = "Send Position"; - } else { - optionsArray[options] = "Send Node Info"; - } - optionsEnumArray[options++] = Position; - - BannerOverlayOptions bannerOptions; -#if defined(M5STACK_UNITC6L) - bannerOptions.message = "Home"; -#else - bannerOptions.message = "Home Action"; -#endif - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.optionsCount = options; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Backlight) { -#if defined(PIN_EINK_EN) - if (uiconfig.screen_brightness == 1) { - uiconfig.screen_brightness = 0; - digitalWrite(PIN_EINK_EN, LOW); - } else { - uiconfig.screen_brightness = 1; - digitalWrite(PIN_EINK_EN, HIGH); - } - saveUIConfig(); -#elif defined(PCA_PIN_EINK_EN) - if (uiconfig.screen_brightness > 0) { - uiconfig.screen_brightness = 0; - io.digitalWrite(PCA_PIN_EINK_EN, LOW); - } else { - uiconfig.screen_brightness = 1; - io.digitalWrite(PCA_PIN_EINK_EN, HIGH); - } - saveUIConfig(); -#endif - } else if (selected == Sleep) { - screen->setOn(false); - } else if (selected == Position) { - InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_SEND_PING, .kbchar = 0, .touchX = 0, .touchY = 0}; - inputBroker->injectInputEvent(&event); - } else if (selected == Preset) { - cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); - } else if (selected == Freetext) { - cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::textMessageMenu() -{ - cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); -} - -void menuHandler::textMessageBaseMenu() -{ - enum optionsNumbers { Back, Preset, Freetext, enumEnd }; - - static const char *optionsArray[enumEnd] = {"Back"}; - static int optionsEnumArray[enumEnd] = {Back}; - int options = 1; - optionsArray[options] = "New Preset Msg"; - optionsEnumArray[options++] = Preset; - if (kb_found) { - optionsArray[options] = "New Freetext Msg"; - optionsEnumArray[options++] = Freetext; - } - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Message Action"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.optionsCount = options; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Preset) { - cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); - } else if (selected == Freetext) { - cannedMessageModule->LaunchFreetextWithDestination(NODENUM_BROADCAST); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::systemBaseMenu() -{ - enum optionsNumbers { Back, Notifications, ScreenOptions, Bluetooth, WiFiToggle, PowerMenu, Test, enumEnd }; - static const char *optionsArray[enumEnd] = {"Back"}; - static int optionsEnumArray[enumEnd] = {Back}; - int options = 1; - - optionsArray[options] = "Notifications"; - optionsEnumArray[options++] = Notifications; - optionsArray[options] = "Display Options"; - optionsEnumArray[options++] = ScreenOptions; - -#if defined(M5STACK_UNITC6L) - optionsArray[options] = "Bluetooth"; -#else - optionsArray[options] = "Bluetooth Toggle"; -#endif - optionsEnumArray[options++] = Bluetooth; -#if HAS_WIFI && !defined(ARCH_PORTDUINO) - optionsArray[options] = "WiFi Toggle"; - optionsEnumArray[options++] = WiFiToggle; -#endif -#if defined(M5STACK_UNITC6L) - optionsArray[options] = "Power"; -#else - optionsArray[options] = "Reboot/Shutdown"; -#endif - optionsEnumArray[options++] = PowerMenu; - - if (test_enabled) { - optionsArray[options] = "Test Menu"; - optionsEnumArray[options++] = Test; - } - - BannerOverlayOptions bannerOptions; -#if defined(M5STACK_UNITC6L) - bannerOptions.message = "System"; -#else - bannerOptions.message = "System Action"; -#endif - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = options; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Notifications) { - menuHandler::menuQueue = menuHandler::notifications_menu; - screen->runNow(); - } else if (selected == ScreenOptions) { - menuHandler::menuQueue = menuHandler::screen_options_menu; - screen->runNow(); - } else if (selected == PowerMenu) { - menuHandler::menuQueue = menuHandler::power_menu; - screen->runNow(); - } else if (selected == Test) { - menuHandler::menuQueue = menuHandler::test_menu; - screen->runNow(); - } else if (selected == Bluetooth) { - menuQueue = bluetooth_toggle_menu; - screen->runNow(); -#if HAS_WIFI && !defined(ARCH_PORTDUINO) - } else if (selected == WiFiToggle) { - menuQueue = wifi_toggle_menu; - screen->runNow(); -#endif - } else if (selected == Back && !test_enabled) { - test_count++; - if (test_count > 4) { - test_enabled = true; - } - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::favoriteBaseMenu() -{ - enum optionsNumbers { Back, Preset, Freetext, GoToChat, Remove, TraceRoute, enumEnd }; - - static const char *optionsArray[enumEnd] = {"Back"}; - static int optionsEnumArray[enumEnd] = {Back}; - int options = 1; - - // Only show "View Conversation" if a message exists with this node - uint32_t peer = graphics::UIRenderer::currentFavoriteNodeNum; - bool hasConversation = false; - for (const auto &m : messageStore.getMessages()) { - if ((m.sender == peer || m.dest == peer)) { - hasConversation = true; - break; - } - } - if (hasConversation) { - optionsArray[options] = "Go To Chat"; - optionsEnumArray[options++] = GoToChat; - } -#if defined(M5STACK_UNITC6L) - optionsArray[options] = "New Preset"; -#else - optionsArray[options] = "New Preset Msg"; -#endif - optionsEnumArray[options++] = Preset; - - if (kb_found) { - optionsArray[options] = "New Freetext Msg"; - optionsEnumArray[options++] = Freetext; - } - -#if !defined(M5STACK_UNITC6L) - optionsArray[options] = "Trace Route"; - optionsEnumArray[options++] = TraceRoute; -#endif - optionsArray[options] = "Remove Favorite"; - optionsEnumArray[options++] = Remove; - - BannerOverlayOptions bannerOptions; -#if defined(M5STACK_UNITC6L) - bannerOptions.message = "Favorites"; -#else - bannerOptions.message = "Favorites Action"; -#endif - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.optionsCount = options; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Preset) { - cannedMessageModule->LaunchWithDestination(graphics::UIRenderer::currentFavoriteNodeNum); - } else if (selected == Freetext) { - cannedMessageModule->LaunchFreetextWithDestination(graphics::UIRenderer::currentFavoriteNodeNum); - } - // Handle new Go To Thread action - else if (selected == GoToChat) { - // Switch thread to direct conversation with this node - graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, - graphics::UIRenderer::currentFavoriteNodeNum); - - // Manually create and send a UIFrameEvent to trigger the jump - UIFrameEvent evt; - evt.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE; - screen->handleUIFrameEvent(&evt); - } else if (selected == Remove) { - menuHandler::menuQueue = menuHandler::remove_favorite; - screen->runNow(); - } else if (selected == TraceRoute) { - if (traceRouteModule) { - traceRouteModule->launch(graphics::UIRenderer::currentFavoriteNodeNum); - } - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::positionBaseMenu() -{ - enum optionsNumbers { Back, GPSToggle, GPSFormat, CompassMenu, CompassCalibrate, enumEnd }; - - static const char *optionsArray[enumEnd] = {"Back", "GPS Toggle", "GPS Format", "Compass"}; - static int optionsEnumArray[enumEnd] = {Back, GPSToggle, GPSFormat, CompassMenu}; - int options = 4; - - if (accelerometerThread) { - optionsArray[options] = "Compass Calibrate"; - optionsEnumArray[options++] = CompassCalibrate; - } - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Position Action"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.optionsCount = options; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == GPSToggle) { - menuQueue = gps_toggle_menu; - screen->runNow(); - } else if (selected == GPSFormat) { - menuQueue = gps_format_menu; - screen->runNow(); - } else if (selected == CompassMenu) { - menuQueue = compass_point_north_menu; - screen->runNow(); - } else if (selected == CompassCalibrate) { - accelerometerThread->calibrate(30); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::nodeListMenu() -{ - enum optionsNumbers { Back, Favorite, TraceRoute, Verify, Reset, NodeNameLength, enumEnd }; - static const char *optionsArray[enumEnd] = {"Back"}; - static int optionsEnumArray[enumEnd] = {Back}; - int options = 1; - - optionsArray[options] = "Add Favorite"; - optionsEnumArray[options++] = Favorite; - optionsArray[options] = "Trace Route"; - optionsEnumArray[options++] = TraceRoute; - -#if !defined(M5STACK_UNITC6L) - optionsArray[options] = "Key Verification"; - optionsEnumArray[options++] = Verify; -#endif - -#if defined(T_DECK) || defined(T_LORA_PAGER) || defined(HACKADAY_COMMUNICATOR) - optionsArray[options] = "Show Long/Short Name"; - optionsEnumArray[options++] = NodeNameLength; -#endif - optionsArray[options] = "Reset NodeDB"; - optionsEnumArray[options++] = Reset; - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Node Action"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = options; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Favorite) { - menuQueue = add_favorite; - screen->runNow(); - } else if (selected == Verify) { - menuQueue = key_verification_init; - screen->runNow(); - } else if (selected == Reset) { - menuQueue = reset_node_db_menu; - screen->runNow(); - } else if (selected == TraceRoute) { - menuQueue = trace_route_menu; - screen->runNow(); - } else if (selected == NodeNameLength) { - menuHandler::menuQueue = menuHandler::node_name_length_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::nodeNameLengthMenu() -{ - enum OptionsNumbers { Back, Long, Short }; - static const char *optionsArray[] = {"Back", "Long", "Short"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Node Name Length"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 3; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Long) { - // Set names to long - LOG_INFO("Setting names to long"); - config.display.use_long_node_name = true; - } else if (selected == Short) { - // Set names to short - LOG_INFO("Setting names to short"); - config.display.use_long_node_name = false; - } else if (selected == Back) { - menuQueue = screen_options_menu; - screen->runNow(); - } - }; - bannerOptions.InitialSelected = config.display.use_long_node_name == true ? 1 : 2; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::resetNodeDBMenu() -{ - static const char *optionsArray[] = {"Back", "Reset All", "Preserve Favorites"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Confirm Reset NodeDB"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 3; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 1 || selected == 2) { - disableBluetooth(); - screen->setFrames(Screen::FOCUS_DEFAULT); - } - if (selected == 1) { - LOG_INFO("Initiate node-db reset"); - nodeDB->resetNodes(); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); - } else if (selected == 2) { - LOG_INFO("Initiate node-db reset but keeping favorites"); - nodeDB->resetNodes(1); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::compassNorthMenu() -{ - enum optionsNumbers { Back, Dynamic, Fixed, Freeze }; - static const char *optionsArray[] = {"Back", "Dynamic", "Fixed Ring", "Freeze Heading"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "North Directions?"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 4; - bannerOptions.InitialSelected = uiconfig.compass_mode + 1; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Dynamic) { - if (uiconfig.compass_mode != meshtastic_CompassMode_DYNAMIC) { - uiconfig.compass_mode = meshtastic_CompassMode_DYNAMIC; - saveUIConfig(); - screen->setFrames(graphics::Screen::FOCUS_PRESERVE); - } - } else if (selected == Fixed) { - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) { - uiconfig.compass_mode = meshtastic_CompassMode_FIXED_RING; - saveUIConfig(); - screen->setFrames(graphics::Screen::FOCUS_PRESERVE); - } - } else if (selected == Freeze) { - if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) { - uiconfig.compass_mode = meshtastic_CompassMode_FREEZE_HEADING; - saveUIConfig(); - screen->setFrames(graphics::Screen::FOCUS_PRESERVE); - } - } else if (selected == Back) { - menuQueue = position_base_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -#if !MESHTASTIC_EXCLUDE_GPS -void menuHandler::GPSToggleMenu() -{ - - static const char *optionsArray[] = {"Back", "Enabled", "Disabled"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Toggle GPS"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 3; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 1) { - config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED; - playGPSEnableBeep(); - gps->enable(); - service->reloadConfig(SEGMENT_CONFIG); - } else if (selected == 2) { - config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED; - playGPSDisableBeep(); - gps->disable(); - service->reloadConfig(SEGMENT_CONFIG); - } else { - menuQueue = position_base_menu; - screen->runNow(); - } - }; - bannerOptions.InitialSelected = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED ? 1 : 2; - screen->showOverlayBanner(bannerOptions); -} -void menuHandler::GPSFormatMenu() -{ - - static const char *optionsArray[] = {"Back", - isHighResolution ? "Decimal Degrees" : "DEC", - isHighResolution ? "Degrees Minutes Seconds" : "DMS", - isHighResolution ? "Universal Transverse Mercator" : "UTM", - isHighResolution ? "Military Grid Reference System" : "MGRS", - isHighResolution ? "Open Location Code" : "OLC", - isHighResolution ? "Ordnance Survey Grid Ref" : "OSGR", - isHighResolution ? "Maidenhead Locator" : "MLS"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "GPS Format"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 8; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 1) { - uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_DEC; - saveUIConfig(); - service->reloadConfig(SEGMENT_CONFIG); - } else if (selected == 2) { - uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS; - saveUIConfig(); - service->reloadConfig(SEGMENT_CONFIG); - } else if (selected == 3) { - uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_UTM; - saveUIConfig(); - service->reloadConfig(SEGMENT_CONFIG); - } else if (selected == 4) { - uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_MGRS; - saveUIConfig(); - service->reloadConfig(SEGMENT_CONFIG); - } else if (selected == 5) { - uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC; - saveUIConfig(); - service->reloadConfig(SEGMENT_CONFIG); - } else if (selected == 6) { - uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_OSGR; - saveUIConfig(); - service->reloadConfig(SEGMENT_CONFIG); - } else if (selected == 7) { - uiconfig.gps_format = meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS; - saveUIConfig(); - service->reloadConfig(SEGMENT_CONFIG); - } else { - menuQueue = position_base_menu; - screen->runNow(); - } - }; - bannerOptions.InitialSelected = uiconfig.gps_format + 1; - screen->showOverlayBanner(bannerOptions); -} -#endif - -void menuHandler::BluetoothToggleMenu() -{ - static const char *optionsArray[] = {"Back", "Enabled", "Disabled"}; - BannerOverlayOptions bannerOptions; -#if defined(M5STACK_UNITC6L) - bannerOptions.message = "Bluetooth"; -#else - bannerOptions.message = "Toggle Bluetooth"; -#endif - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 3; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 0) - return; - else if (selected != (config.bluetooth.enabled ? 1 : 2)) { - InputEvent event = {.inputEvent = (input_broker_event)170, .kbchar = 170, .touchX = 0, .touchY = 0}; - inputBroker->injectInputEvent(&event); - } - }; - bannerOptions.InitialSelected = config.bluetooth.enabled ? 1 : 2; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::BuzzerModeMenu() -{ - static const char *optionsArray[] = {"All Enabled", "Disabled", "Notifications", "System Only", "DMs Only"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Buzzer Mode"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 5; - bannerOptions.bannerCallback = [](int selected) -> void { - config.device.buzzer_mode = (meshtastic_Config_DeviceConfig_BuzzerMode)selected; - service->reloadConfig(SEGMENT_CONFIG); - }; - bannerOptions.InitialSelected = config.device.buzzer_mode; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::BrightnessPickerMenu() -{ - static const char *optionsArray[] = {"Back", "Low", "Medium", "High"}; - - // Get current brightness level to set initial selection - int currentSelection = 1; // Default to Medium - if (uiconfig.screen_brightness >= 255) { - currentSelection = 3; // Very High - } else if (uiconfig.screen_brightness >= 128) { - currentSelection = 2; // High - } else { - currentSelection = 1; // Medium - } - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Brightness"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 4; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 1) { // Medium - uiconfig.screen_brightness = 64; - } else if (selected == 2) { // High - uiconfig.screen_brightness = 128; - } else if (selected == 3) { // Very High - uiconfig.screen_brightness = 255; - } - - if (selected != 0) { // Not "Back" - // Apply brightness immediately -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) - // For HELTEC devices, use analogWrite to control backlight - analogWrite(VTFT_LEDA, uiconfig.screen_brightness); -#elif defined(ST7789_CS) || defined(ST7796_CS) - static_cast(screen->getDisplayDevice())->setDisplayBrightness(uiconfig.screen_brightness); -#elif defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) - screen->getDisplayDevice()->setBrightness(uiconfig.screen_brightness); -#endif - - // Save to device - saveUIConfig(); - - LOG_INFO("Screen brightness set to %d", uiconfig.screen_brightness); - } - }; - bannerOptions.InitialSelected = currentSelection; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::switchToMUIMenu() -{ - static const char *optionsArray[] = {"No", "Yes"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Switch to MUI?"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 2; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 1) { - config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR; - config.bluetooth.enabled = false; - service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::TFTColorPickerMenu(OLEDDisplay *display) -{ - static const char *optionsArray[] = { - "Back", "Default", "Meshtastic Green", "Yellow", "Red", "Orange", "Purple", "Blue", "Teal", "Cyan", "Ice", "Pink", - "White", "Gray"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Select Screen Color"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 14; - bannerOptions.bannerCallback = [display](int selected) -> void { -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \ - HAS_TFT || defined(HACKADAY_COMMUNICATOR) - uint8_t TFT_MESH_r = 0; - uint8_t TFT_MESH_g = 0; - uint8_t TFT_MESH_b = 0; - if (selected == 1) { - LOG_INFO("Setting color to system default or defined variant"); - // Given just before we set all these to zero, we will allow this to go through - } else if (selected == 2) { - LOG_INFO("Setting color to Meshtastic Green"); - TFT_MESH_r = 103; - TFT_MESH_g = 234; - TFT_MESH_b = 148; - } else if (selected == 3) { - LOG_INFO("Setting color to Yellow"); - TFT_MESH_r = 255; - TFT_MESH_g = 255; - TFT_MESH_b = 128; - } else if (selected == 4) { - LOG_INFO("Setting color to Red"); - TFT_MESH_r = 255; - TFT_MESH_g = 64; - TFT_MESH_b = 64; - } else if (selected == 5) { - LOG_INFO("Setting color to Orange"); - TFT_MESH_r = 255; - TFT_MESH_g = 160; - TFT_MESH_b = 20; - } else if (selected == 6) { - LOG_INFO("Setting color to Purple"); - TFT_MESH_r = 204; - TFT_MESH_g = 153; - TFT_MESH_b = 255; - } else if (selected == 7) { - LOG_INFO("Setting color to Blue"); - TFT_MESH_r = 0; - TFT_MESH_g = 0; - TFT_MESH_b = 255; - } else if (selected == 8) { - LOG_INFO("Setting color to Teal"); - TFT_MESH_r = 16; - TFT_MESH_g = 102; - TFT_MESH_b = 102; - } else if (selected == 9) { - LOG_INFO("Setting color to Cyan"); - TFT_MESH_r = 0; - TFT_MESH_g = 255; - TFT_MESH_b = 255; - } else if (selected == 10) { - LOG_INFO("Setting color to Ice"); - TFT_MESH_r = 173; - TFT_MESH_g = 216; - TFT_MESH_b = 230; - } else if (selected == 11) { - LOG_INFO("Setting color to Pink"); - TFT_MESH_r = 255; - TFT_MESH_g = 105; - TFT_MESH_b = 180; - } else if (selected == 12) { - LOG_INFO("Setting color to White"); - TFT_MESH_r = 255; - TFT_MESH_g = 255; - TFT_MESH_b = 255; - } else if (selected == 13) { - LOG_INFO("Setting color to Gray"); - TFT_MESH_r = 128; - TFT_MESH_g = 128; - TFT_MESH_b = 128; - } else { - menuQueue = system_base_menu; - screen->runNow(); - } - - if (selected != 0) { - display->setColor(BLACK); - display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); - display->setColor(WHITE); - - if (TFT_MESH_r == 0 && TFT_MESH_g == 0 && TFT_MESH_b == 0) { -#ifdef TFT_MESH_OVERRIDE - TFT_MESH = TFT_MESH_OVERRIDE; -#else - TFT_MESH = COLOR565(0x67, 0xEA, 0x94); -#endif - } else { - TFT_MESH = COLOR565(TFT_MESH_r, TFT_MESH_g, TFT_MESH_b); - } - -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) - static_cast(screen->getDisplayDevice())->setRGB(TFT_MESH); -#endif - - screen->setFrames(graphics::Screen::FOCUS_SYSTEM); - if (TFT_MESH_r == 0 && TFT_MESH_g == 0 && TFT_MESH_b == 0) { - uiconfig.screen_rgb_color = 0; - } else { - uiconfig.screen_rgb_color = (TFT_MESH_r << 16) | (TFT_MESH_g << 8) | TFT_MESH_b; - } - LOG_INFO("Storing Value of %d to uiconfig.screen_rgb_color", uiconfig.screen_rgb_color); - saveUIConfig(); - } -#endif - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::rebootMenu() -{ - static const char *optionsArray[] = {"Back", "Confirm"}; - BannerOverlayOptions bannerOptions; -#if defined(M5STACK_UNITC6L) - bannerOptions.message = "Reboot"; -#else - bannerOptions.message = "Reboot Device?"; -#endif - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 2; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 1) { - IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0)); - nodeDB->saveToDisk(); - messageStore.saveToFlash(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; - } else { - menuQueue = power_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::shutdownMenu() -{ - static const char *optionsArray[] = {"Back", "Confirm"}; - BannerOverlayOptions bannerOptions; -#if defined(M5STACK_UNITC6L) - bannerOptions.message = "Shutdown"; -#else - bannerOptions.message = "Shutdown Device?"; -#endif - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 2; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 1) { - InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_SHUTDOWN, .kbchar = 0, .touchX = 0, .touchY = 0}; - inputBroker->injectInputEvent(&event); - } else { - menuQueue = power_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::addFavoriteMenu() -{ -#if defined(M5STACK_UNITC6L) - screen->showNodePicker("Node Favorite", 30000, [](uint32_t nodenum) -> void { -#else - screen->showNodePicker("Node To Favorite", 30000, [](uint32_t nodenum) -> void { - -#endif - LOG_WARN("Nodenum: %u", nodenum); - nodeDB->set_favorite(true, nodenum); - screen->setFrames(graphics::Screen::FOCUS_PRESERVE); - }); -} - -void menuHandler::removeFavoriteMenu() -{ - - static const char *optionsArray[] = {"Back", "Yes"}; - BannerOverlayOptions bannerOptions; - std::string message = "Unfavorite This Node?\n"; - auto node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum); - if (node && node->has_user) { - message += sanitizeString(node->user.long_name).substr(0, 15); - } - bannerOptions.message = message.c_str(); - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 2; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == 1) { - LOG_INFO("Removing %x as favorite node", graphics::UIRenderer::currentFavoriteNodeNum); - nodeDB->set_favorite(false, graphics::UIRenderer::currentFavoriteNodeNum); - screen->setFrames(graphics::Screen::FOCUS_DEFAULT); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::traceRouteMenu() -{ - screen->showNodePicker("Node to Trace", 30000, [](uint32_t nodenum) -> void { - LOG_INFO("Menu: Node picker selected node 0x%08x, traceRouteModule=%p", nodenum, traceRouteModule); - if (traceRouteModule) { - traceRouteModule->startTraceRoute(nodenum); - } - }); -} - -void menuHandler::testMenu() -{ - - enum optionsNumbers { Back, NumberPicker, ShowChirpy }; - static const char *optionsArray[4] = {"Back"}; - static int optionsEnumArray[4] = {Back}; - int options = 1; - - optionsArray[options] = "Number Picker"; - optionsEnumArray[options++] = NumberPicker; - - optionsArray[options] = screen->isFrameHidden("chirpy") ? "Show Chirpy" : "Hide Chirpy"; - optionsEnumArray[options++] = ShowChirpy; - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Hidden Test Menu"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = options; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == NumberPicker) { - menuQueue = number_test; - screen->runNow(); - } else if (selected == ShowChirpy) { - screen->toggleFrameVisibility("chirpy"); - screen->setFrames(Screen::FOCUS_SYSTEM); - - } else { - menuQueue = system_base_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::numberTest() -{ - screen->showNumberPicker("Pick a number\n ", 30000, 4, - [](int number_picked) -> void { LOG_WARN("Nodenum: %u", number_picked); }); -} - -void menuHandler::wifiBaseMenu() -{ - enum optionsNumbers { Back, Wifi_toggle }; - - static const char *optionsArray[] = {"Back", "WiFi Toggle"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "WiFi Menu"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 2; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Wifi_toggle) { - menuQueue = wifi_toggle_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::wifiToggleMenu() -{ - enum optionsNumbers { Back, Wifi_disable, Wifi_enable }; - - static const char *optionsArray[] = {"Back", "WiFi Disabled", "WiFi Enabled"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = "WiFi Actions"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 3; - if (config.network.wifi_enabled == true) - bannerOptions.InitialSelected = 2; - else - bannerOptions.InitialSelected = 1; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Wifi_disable) { - config.network.wifi_enabled = false; - config.bluetooth.enabled = true; - service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); - } else if (selected == Wifi_enable) { - config.network.wifi_enabled = true; - config.bluetooth.enabled = false; - service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::notificationsMenu() -{ - enum optionsNumbers { Back, BuzzerActions }; - static const char *optionsArray[] = {"Back", "Buzzer Actions"}; - static int optionsEnumArray[] = {Back, BuzzerActions}; - int options = 2; - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Notifications"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = options; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == BuzzerActions) { - menuHandler::menuQueue = menuHandler::buzzermodemenupicker; - screen->runNow(); - } else { - menuQueue = system_base_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::screenOptionsMenu() -{ - // Check if brightness is supported - bool hasSupportBrightness = false; -#if defined(ST7789_CS) || defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) - hasSupportBrightness = true; -#endif - -#if defined(T_DECK) - // TDeck Doesn't seem to support brightness at all, at least not reliably - hasSupportBrightness = false; -#endif - - enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits }; - static const char *optionsArray[5] = {"Back"}; - static int optionsEnumArray[5] = {Back}; - int options = 1; - - // Only show brightness for B&W displays - if (hasSupportBrightness) { - optionsArray[options] = "Brightness"; - optionsEnumArray[options++] = Brightness; - } - - // Only show screen color for TFT displays -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \ - HAS_TFT || defined(HACKADAY_COMMUNICATOR) - optionsArray[options] = "Screen Color"; - optionsEnumArray[options++] = ScreenColor; -#endif - - optionsArray[options] = "Frame Visibility Toggle"; - optionsEnumArray[options++] = FrameToggles; - - optionsArray[options] = "Display Units"; - optionsEnumArray[options++] = DisplayUnits; - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Display Options"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = options; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Brightness) { - menuHandler::menuQueue = menuHandler::brightness_picker; - screen->runNow(); - } else if (selected == ScreenColor) { - menuHandler::menuQueue = menuHandler::tftcolormenupicker; - screen->runNow(); - } else if (selected == FrameToggles) { - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == DisplayUnits) { - menuHandler::menuQueue = menuHandler::DisplayUnits; - screen->runNow(); - } else { - menuQueue = system_base_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::powerMenu() -{ - - enum optionsNumbers { Back, Reboot, Shutdown, MUI }; - static const char *optionsArray[4] = {"Back"}; - static int optionsEnumArray[4] = {Back}; - int options = 1; - - optionsArray[options] = "Reboot"; - optionsEnumArray[options++] = Reboot; - - optionsArray[options] = "Shutdown"; - optionsEnumArray[options++] = Shutdown; - -#if HAS_TFT - optionsArray[options] = "Switch to MUI"; - optionsEnumArray[options++] = MUI; -#endif - - BannerOverlayOptions bannerOptions; -#if defined(M5STACK_UNITC6L) - bannerOptions.message = "Power"; -#else - bannerOptions.message = "Reboot / Shutdown"; -#endif - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = options; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Reboot) { - menuHandler::menuQueue = menuHandler::reboot_menu; - screen->runNow(); - } else if (selected == Shutdown) { - menuHandler::menuQueue = menuHandler::shutdown_menu; - screen->runNow(); - } else if (selected == MUI) { - menuHandler::menuQueue = menuHandler::mui_picker; - screen->runNow(); - } else { - menuQueue = system_base_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::keyVerificationInitMenu() -{ - screen->showNodePicker("Node to Verify", 30000, - [](uint32_t selected) -> void { keyVerificationModule->sendInitialRequest(selected); }); -} - -void menuHandler::keyVerificationFinalPrompt() -{ - char message[40] = {0}; - memset(message, 0, sizeof(message)); - sprintf(message, "Verification: \n"); - keyVerificationModule->generateVerificationCode(message + 15); // send the toPhone packet - - if (screen) { - static const char *optionsArray[] = {"Reject", "Accept"}; - graphics::BannerOverlayOptions options; - options.message = message; - options.durationMs = 30000; - options.optionsArrayPtr = optionsArray; - options.optionsCount = 2; - options.notificationType = graphics::notificationTypeEnum::selection_picker; - options.bannerCallback = [=](int selected) { - if (selected == 1) { - auto remoteNodePtr = nodeDB->getMeshNode(keyVerificationModule->getCurrentRemoteNode()); - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; - } - }; - screen->showOverlayBanner(options); - } -} - -void menuHandler::FrameToggles_menu() -{ - enum optionsNumbers { - Finish, - nodelist_nodes, - nodelist_location, - nodelist_lastheard, - nodelist_hopsignal, - nodelist_distance, - nodelist_bearings, - gps, - lora, - clock, - show_favorites, - show_telemetry, - show_power, - enumEnd - }; - static const char *optionsArray[enumEnd] = {"Finish"}; - static int optionsEnumArray[enumEnd] = {Finish}; - int options = 1; - - // Track last selected index (not enum value!) - static int lastSelectedIndex = 0; - -#ifndef USE_EINK - optionsArray[options] = screen->isFrameHidden("nodelist_nodes") ? "Show Node List" : "Hide Node List"; - optionsEnumArray[options++] = nodelist_nodes; -#else - optionsArray[options] = screen->isFrameHidden("nodelist_lastheard") ? "Show NL - Last Heard" : "Hide NL - Last Heard"; - optionsEnumArray[options++] = nodelist_lastheard; - optionsArray[options] = screen->isFrameHidden("nodelist_hopsignal") ? "Show NL - Hops/Signal" : "Hide NL - Hops/Signal"; - optionsEnumArray[options++] = nodelist_hopsignal; -#endif - -#if HAS_GPS -#ifndef USE_EINK - optionsArray[options] = screen->isFrameHidden("nodelist_location") ? "Show Node Location List" : "Hide Node Location List"; - optionsEnumArray[options++] = nodelist_location; -#else - optionsArray[options] = screen->isFrameHidden("nodelist_distance") ? "Show NL - Distance" : "Hide NL - Distance"; - optionsEnumArray[options++] = nodelist_distance; - optionsArray[options] = screen->isFrameHidden("nodelist_bearings") ? "Show NL - Bearings" : "Hide NL - Bearings"; - optionsEnumArray[options++] = nodelist_bearings; -#endif - - optionsArray[options] = screen->isFrameHidden("gps") ? "Show Position" : "Hide Position"; - optionsEnumArray[options++] = gps; -#endif - - optionsArray[options] = screen->isFrameHidden("lora") ? "Show LoRa" : "Hide LoRa"; - optionsEnumArray[options++] = lora; - - optionsArray[options] = screen->isFrameHidden("clock") ? "Show Clock" : "Hide Clock"; - optionsEnumArray[options++] = clock; - - optionsArray[options] = screen->isFrameHidden("show_favorites") ? "Show Favorites" : "Hide Favorites"; - optionsEnumArray[options++] = show_favorites; - - optionsArray[options] = moduleConfig.telemetry.environment_screen_enabled ? "Hide Telemetry" : "Show Telemetry"; - optionsEnumArray[options++] = show_telemetry; - - optionsArray[options] = moduleConfig.telemetry.power_screen_enabled ? "Hide Power" : "Show Power"; - optionsEnumArray[options++] = show_power; - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Show/Hide Frames"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = options; - bannerOptions.optionsEnumPtr = optionsEnumArray; - bannerOptions.InitialSelected = lastSelectedIndex; // Use index, not enum value - - bannerOptions.bannerCallback = [options](int selected) mutable -> void { - // Find the index of selected in optionsEnumArray - int idx = 0; - for (; idx < options; ++idx) { - if (optionsEnumArray[idx] == selected) - break; - } - lastSelectedIndex = idx; - - if (selected == Finish) { - screen->setFrames(Screen::FOCUS_DEFAULT); - } else if (selected == nodelist_nodes) { - screen->toggleFrameVisibility("nodelist_nodes"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == nodelist_location) { - screen->toggleFrameVisibility("nodelist_location"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == nodelist_lastheard) { - screen->toggleFrameVisibility("nodelist_lastheard"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == nodelist_hopsignal) { - screen->toggleFrameVisibility("nodelist_hopsignal"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == nodelist_distance) { - screen->toggleFrameVisibility("nodelist_distance"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == nodelist_bearings) { - screen->toggleFrameVisibility("nodelist_bearings"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == gps) { - screen->toggleFrameVisibility("gps"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == lora) { - screen->toggleFrameVisibility("lora"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == clock) { - screen->toggleFrameVisibility("clock"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == show_favorites) { - screen->toggleFrameVisibility("show_favorites"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == show_telemetry) { - moduleConfig.telemetry.environment_screen_enabled = !moduleConfig.telemetry.environment_screen_enabled; - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } else if (selected == show_power) { - moduleConfig.telemetry.power_screen_enabled = !moduleConfig.telemetry.power_screen_enabled; - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::DisplayUnits_menu() -{ - enum optionsNumbers { Back, MetricUnits, ImperialUnits }; - - static const char *optionsArray[] = {"Back", "Metric", "Imperial"}; - BannerOverlayOptions bannerOptions; - bannerOptions.message = " Select display units"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 3; - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) - bannerOptions.InitialSelected = 2; - else - bannerOptions.InitialSelected = 1; - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == MetricUnits) { - config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC; - service->reloadConfig(SEGMENT_CONFIG); - } else if (selected == ImperialUnits) { - config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL; - service->reloadConfig(SEGMENT_CONFIG); - } else { - menuHandler::menuQueue = menuHandler::screen_options_menu; - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - -void menuHandler::handleMenuSwitch(OLEDDisplay *display) -{ - if (menuQueue != menu_none) - test_count = 0; - switch (menuQueue) { - case menu_none: - break; - case lora_Menu: - loraMenu(); - break; - case lora_picker: - LoraRegionPicker(); - break; - case device_role_picker: - DeviceRolePicker(); - break; - case radio_preset_picker: - RadioPresetPicker(); - break; - case no_timeout_lora_picker: - LoraRegionPicker(0); - break; - case TZ_picker: - TZPicker(); - break; - case twelve_hour_picker: - TwelveHourPicker(); - break; - case clock_face_picker: - ClockFacePicker(); - break; - case clock_menu: - clockMenu(); - break; - case system_base_menu: - systemBaseMenu(); - break; - case position_base_menu: - positionBaseMenu(); - break; -#if !MESHTASTIC_EXCLUDE_GPS - case gps_toggle_menu: - GPSToggleMenu(); - break; - case gps_format_menu: - GPSFormatMenu(); - break; -#endif - case compass_point_north_menu: - compassNorthMenu(); - break; - case reset_node_db_menu: - resetNodeDBMenu(); - break; - case buzzermodemenupicker: - BuzzerModeMenu(); - break; - case mui_picker: - switchToMUIMenu(); - break; - case tftcolormenupicker: - TFTColorPickerMenu(display); - break; - case brightness_picker: - BrightnessPickerMenu(); - break; - case node_name_length_menu: - nodeNameLengthMenu(); - break; - case reboot_menu: - rebootMenu(); - break; - case shutdown_menu: - shutdownMenu(); - break; - case add_favorite: - addFavoriteMenu(); - break; - case remove_favorite: - removeFavoriteMenu(); - break; - case trace_route_menu: - traceRouteMenu(); - break; - case test_menu: - testMenu(); - break; - case number_test: - numberTest(); - break; - case wifi_toggle_menu: - wifiToggleMenu(); - break; - case key_verification_init: - keyVerificationInitMenu(); - break; - case key_verification_final_prompt: - keyVerificationFinalPrompt(); - break; - case bluetooth_toggle_menu: - BluetoothToggleMenu(); - break; - case notifications_menu: - notificationsMenu(); - break; - case screen_options_menu: - screenOptionsMenu(); - break; - case power_menu: - powerMenu(); - break; - case FrameToggles: - FrameToggles_menu(); - break; - case DisplayUnits: - DisplayUnits_menu(); - break; - case throttle_message: - screen->showSimpleBanner("Too Many Attempts\nTry again in 60 seconds.", 5000); - break; - case message_response_menu: - messageResponseMenu(); - break; - case reply_menu: - replyMenu(); - break; - case delete_messages_menu: - deleteMessagesMenu(); - break; - case message_viewmode_menu: - messageViewModeMenu(); - break; - } - menuQueue = menu_none; -} - -void menuHandler::saveUIConfig() -{ - nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uiconfig); -} - -} // namespace graphics - -#endif \ No newline at end of file diff --git a/NodeListRenderer.cpp b/NodeListRenderer.cpp deleted file mode 100644 index a1020268e07..00000000000 --- a/NodeListRenderer.cpp +++ /dev/null @@ -1,698 +0,0 @@ -#include "configuration.h" -#if HAS_SCREEN -#include "CompassRenderer.h" -#include "NodeDB.h" -#include "NodeListRenderer.h" -#include "UIRenderer.h" -#include "gps/GeoCoord.h" -#include "gps/RTC.h" // for getTime() function -#include "graphics/ScreenFonts.h" -#include "graphics/SharedUIDisplay.h" -#include "graphics/images.h" -#include "meshUtils.h" -#include - -// Forward declarations for functions defined in Screen.cpp -namespace graphics -{ -extern bool haveGlyphs(const char *str); -} // namespace graphics - -// Global screen instance -extern graphics::Screen *screen; - -#if defined(M5STACK_UNITC6L) -static uint32_t lastSwitchTime = 0; -#else -#endif -namespace graphics -{ -namespace NodeListRenderer -{ - -// Function moved from Screen.cpp to NodeListRenderer.cpp since it's primarily used here -void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display) -{ - for (int row = 0; row < height; row++) { - uint8_t rowMask = (1 << row); - for (int col = 0; col < width; col++) { - uint8_t colData = pgm_read_byte(&bitmapXBM[col]); - if (colData & rowMask) { - // Note: rows become X, columns become Y after transpose - display->fillRect(x + row * 2, y + col * 2, 2, 2); - } - } - } -} - -// Static variables for dynamic cycling -static ListMode_Node currentMode_Nodes = MODE_LAST_HEARD; -static ListMode_Location currentMode_Location = MODE_DISTANCE; -static int scrollIndex = 0; - -// ============================= -// Utility Functions -// ============================= - -const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node) -{ - static char nodeName[25]; // single static buffer we return - nodeName[0] = '\0'; - - auto writeFallbackId = [&] { - std::snprintf(nodeName, sizeof(nodeName), "(%04X)", static_cast(node ? (node->num & 0xFFFF) : 0)); - }; - - // 1) Choose target candidate (long vs short) only if present - const char *raw = nullptr; - if (node && node->has_user) { - raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name; - } - - // 2) Sanitize (empty if raw is null/empty) - std::string s = (raw && *raw) ? sanitizeString(raw) : std::string{}; - - // 3) Fallback if sanitize yields empty; otherwise copy safely (truncate if needed) - if (s.empty() || s == "¿" || s.find_first_not_of("¿") == std::string::npos) { - writeFallbackId(); - } else { - // %.*s ensures null-termination and safe truncation to buffer size - 1 - std::snprintf(nodeName, sizeof(nodeName), "%.*s", static_cast(sizeof(nodeName) - 1), s.c_str()); - } - - // 4) Width-based truncation + ellipsis (long-name mode only) - if (config.display.use_long_node_name && display) { - int availWidth = (SCREEN_WIDTH / 2) - 65; - if (availWidth < 0) - availWidth = 0; - - const size_t beforeLen = std::strlen(nodeName); - - // Trim from the end until it fits or is empty - size_t len = beforeLen; - while (len && display->getStringWidth(nodeName) > availWidth) { - nodeName[--len] = '\0'; - } - - // If truncated, append "..." (respect buffer size) - if (len < beforeLen) { - // Make sure there's room for "..." and '\0' - const size_t capForText = sizeof(nodeName) - 1; // leaving space for '\0' - const size_t needed = 3; // "..." - if (len > capForText - needed) { - len = capForText - needed; - nodeName[len] = '\0'; - } - std::strcat(nodeName, "..."); - } - } - - return nodeName; -} - -const char *getCurrentModeTitle_Nodes(int screenWidth) -{ - switch (currentMode_Nodes) { - case MODE_LAST_HEARD: - return "Last Heard"; - case MODE_HOP_SIGNAL: -#ifdef USE_EINK - return "Hops/Sig"; -#else - return (isHighResolution) ? "Hops/Signal" : "Hops/Sig"; -#endif - default: - return "Nodes"; - } -} - -const char *getCurrentModeTitle_Location(int screenWidth) -{ - switch (currentMode_Location) { - case MODE_DISTANCE: - return "Distance"; - case MODE_BEARING: - return "Bearings"; - default: - return "Nodes"; - } -} - -// Use dynamic timing based on mode -unsigned long getModeCycleIntervalMs() -{ - return 3000; -} - -int calculateMaxScroll(int totalEntries, int visibleRows) -{ - return std::max(0, (totalEntries - 1) / (visibleRows * 2)); -} - -void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd) -{ - for (int y = yStart; y <= yEnd; y += 2) { - display->setPixel(x, y); - } -} - -void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, int scrollIndex, int columns, int scrollStartY) -{ - if (totalEntries <= visibleNodeRows * columns) - return; - - int scrollbarX = display->getWidth() - 2; - int scrollbarHeight = display->getHeight() - scrollStartY - 10; - int thumbHeight = std::max(4, (scrollbarHeight * visibleNodeRows * columns) / totalEntries); - int maxScroll = calculateMaxScroll(totalEntries, visibleNodeRows); - int thumbY = scrollStartY + (scrollIndex * (scrollbarHeight - thumbHeight)) / std::max(1, maxScroll); - - for (int i = 0; i < thumbHeight; i++) { - display->setPixel(scrollbarX, thumbY + i); - } -} - -// ============================= -// Entry Renderers -// ============================= - -void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) -{ - bool isLeftCol = (x < SCREEN_WIDTH / 2); - int timeOffset = (isHighResolution) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7); - - const char *nodeName = getSafeNodeName(display, node); - - char timeStr[10]; - uint32_t seconds = sinceLastSeen(node); - if (seconds == 0 || seconds == UINT32_MAX) { - snprintf(timeStr, sizeof(timeStr), "?"); - } else { - uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24; - snprintf(timeStr, sizeof(timeStr), (days > 365 ? "?" : "%d%c"), - (days ? days - : hours ? hours - : minutes), - (days ? 'd' - : hours ? 'h' - : 'm')); - } - - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - display->drawString(x + ((isHighResolution) ? 6 : 3), y, nodeName); - if (node->is_favorite) { - if (isHighResolution) { - drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); - } else { - display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); - } - } - - int rightEdge = x + columnWidth - timeOffset; - if (timeStr[strlen(timeStr) - 1] == 'm') // Fix the fact that our fonts don't line up well all the time - rightEdge -= 1; - int textWidth = display->getStringWidth(timeStr); - display->drawString(rightEdge - textWidth, y, timeStr); -} - -void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) -{ - bool isLeftCol = (x < SCREEN_WIDTH / 2); - - int nameMaxWidth = columnWidth - 25; - int barsOffset = (isHighResolution) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19); - int hopOffset = (isHighResolution) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17); - - int barsXOffset = columnWidth - barsOffset; - - const char *nodeName = getSafeNodeName(display, node); - - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - - display->drawStringMaxWidth(x + ((isHighResolution) ? 6 : 3), y, nameMaxWidth, nodeName); - if (node->is_favorite) { - if (isHighResolution) { - drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); - } else { - display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); - } - } - - // Draw signal strength bars - int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0; - int barWidth = 2; - int barStartX = x + barsXOffset; - int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2; - - for (int b = 0; b < 4; b++) { - if (b < bars) { - int height = (b * 2); - display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height); - } - } - - // Draw hop count - char hopStr[6] = ""; - if (node->has_hops_away && node->hops_away > 0) - snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away); - - if (hopStr[0] != '\0') { - int rightEdge = x + columnWidth - hopOffset; - int textWidth = display->getStringWidth(hopStr); - display->drawString(rightEdge - textWidth, y, hopStr); - } -} - -void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) -{ - bool isLeftCol = (x < SCREEN_WIDTH / 2); - int nameMaxWidth = columnWidth - (isHighResolution ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); - - const char *nodeName = getSafeNodeName(display, node); - char distStr[10] = ""; - - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) { - double lat1 = ourNode->position.latitude_i * 1e-7; - double lon1 = ourNode->position.longitude_i * 1e-7; - double lat2 = node->position.latitude_i * 1e-7; - double lon2 = node->position.longitude_i * 1e-7; - - double earthRadiusKm = 6371.0; - double dLat = (lat2 - lat1) * DEG_TO_RAD; - double dLon = (lon2 - lon1) * DEG_TO_RAD; - - double a = - sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2); - double c = 2 * atan2(sqrt(a), sqrt(1 - a)); - double distanceKm = earthRadiusKm * c; - - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - double miles = distanceKm * 0.621371; - if (miles < 0.1) { - int feet = (int)(miles * 5280); - if (feet < 1000) - snprintf(distStr, sizeof(distStr), "%dft", feet); - else - snprintf(distStr, sizeof(distStr), "¼mi"); // 4-char max - } else { - int roundedMiles = (int)(miles + 0.5); - if (roundedMiles < 1000) - snprintf(distStr, sizeof(distStr), "%dmi", roundedMiles); - else - snprintf(distStr, sizeof(distStr), "999"); // Max display cap - } - } else { - if (distanceKm < 1.0) { - int meters = (int)(distanceKm * 1000); - if (meters < 1000) - snprintf(distStr, sizeof(distStr), "%dm", meters); - else - snprintf(distStr, sizeof(distStr), "1k"); - } else { - int km = (int)(distanceKm + 0.5); - if (km < 1000) - snprintf(distStr, sizeof(distStr), "%dk", km); - else - snprintf(distStr, sizeof(distStr), "999"); - } - } - } - - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - display->drawStringMaxWidth(x + ((isHighResolution) ? 6 : 3), y, nameMaxWidth, nodeName); - if (node->is_favorite) { - if (isHighResolution) { - drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); - } else { - display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); - } - } - - if (strlen(distStr) > 0) { - int offset = (isHighResolution) ? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column) - : (isLeftCol ? 4 : 7); // Offset for Narrow Screens (Left Column:Right Column) - int rightEdge = x + columnWidth - offset; - int textWidth = display->getStringWidth(distStr); - display->drawString(rightEdge - textWidth, y, distStr); - } -} - -void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) -{ - switch (currentMode_Nodes) { - case MODE_LAST_HEARD: - drawEntryLastHeard(display, node, x, y, columnWidth); - break; - case MODE_HOP_SIGNAL: - drawEntryHopSignal(display, node, x, y, columnWidth); - break; - default: - break; - } -} - -void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) -{ - bool isLeftCol = (x < SCREEN_WIDTH / 2); - - // Adjust max text width depending on column and screen width - int nameMaxWidth = columnWidth - (isHighResolution ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); - - const char *nodeName = getSafeNodeName(display, node); - - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - display->drawStringMaxWidth(x + ((isHighResolution) ? 6 : 3), y, nameMaxWidth, nodeName); - if (node->is_favorite) { - if (isHighResolution) { - drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); - } else { - display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint); - } - } -} - -void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading, - double userLat, double userLon) -{ - if (!nodeDB->hasValidPosition(node)) - return; - - bool isLeftCol = (x < SCREEN_WIDTH / 2); - int arrowXOffset = (isHighResolution) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18); - - int centerX = x + columnWidth - arrowXOffset; - int centerY = y + FONT_HEIGHT_SMALL / 2; - - double nodeLat = node->position.latitude_i * 1e-7; - double nodeLon = node->position.longitude_i * 1e-7; - float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon); - float bearingToNode = RAD_TO_DEG * bearing; - float relativeBearing = fmod((bearingToNode - myHeading + 360), 360); - // Shrink size by 2px - int size = FONT_HEIGHT_SMALL - 5; - CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearing); - /* - float angle = relativeBearing * DEG_TO_RAD; - float halfSize = size / 2.0; - - // Point of the arrow - int tipX = centerX + halfSize * cos(angle); - int tipY = centerY - halfSize * sin(angle); - - float baseAngle = radians(35); - float sideLen = halfSize * 0.95; - float notchInset = halfSize * 0.35; - - // Left and right corners - int leftX = centerX + sideLen * cos(angle + PI - baseAngle); - int leftY = centerY - sideLen * sin(angle + PI - baseAngle); - - int rightX = centerX + sideLen * cos(angle + PI + baseAngle); - int rightY = centerY - sideLen * sin(angle + PI + baseAngle); - - // Center notch (cut-in) - int notchX = centerX - notchInset * cos(angle); - int notchY = centerY + notchInset * sin(angle); - - // Draw the chevron-style arrowhead - display->fillTriangle(tipX, tipY, leftX, leftY, notchX, notchY); - display->fillTriangle(tipX, tipY, notchX, notchY, rightX, rightY); - */ -} - -// ============================= -// Main Screen Functions -// ============================= - -void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, - EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon) -{ - const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1; - const int rowYOffset = FONT_HEIGHT_SMALL - 3; - bool locationScreen = false; - - if (strcmp(title, "Bearings") == 0) - locationScreen = true; - else if (strcmp(title, "Distance") == 0) - locationScreen = true; -#if defined(M5STACK_UNITC6L) - int columnWidth = display->getWidth(); -#elif defined(T_LORA_PAGER) - int columnWidth = display->getWidth() / 3; - if (config.display.use_long_node_name) { - columnWidth = display->getWidth() / 2; - } -#else - int columnWidth = display->getWidth() / 2; -#endif - - display->clear(); - - // Draw the battery/time header - graphics::drawCommonHeader(display, x, y, title); - - // Space below header - y += COMMON_HEADER_HEIGHT; - - int totalEntries = nodeDB->getNumMeshNodes(); - int totalRowsAvailable = (display->getHeight() - y) / rowYOffset; - int numskipped = 0; - int visibleNodeRows = totalRowsAvailable; -#if defined(M5STACK_UNITC6L) - int totalColumns = 1; -#elif defined(T_LORA_PAGER) - int totalColumns = 3; - if (config.display.use_long_node_name) { - totalColumns = 2; - } -#else - int totalColumns = 2; -#endif - int startIndex = scrollIndex * visibleNodeRows * totalColumns; - if (nodeDB->getMeshNodeByIndex(startIndex)->num == nodeDB->getNodeNum()) { - startIndex++; // skip own node - } - int endIndex = std::min(startIndex + visibleNodeRows * totalColumns, totalEntries); - - int yOffset = 0; - int col = 0; - int lastNodeY = y; - int shownCount = 0; - int rowCount = 0; - - for (int i = startIndex; i < endIndex; ++i) { - if (locationScreen && !nodeDB->getMeshNodeByIndex(i)->has_position) { - numskipped++; - continue; - } - int xPos = x + (col * columnWidth); - int yPos = y + yOffset; - renderer(display, nodeDB->getMeshNodeByIndex(i), xPos, yPos, columnWidth); - - if (extras) { - extras(display, nodeDB->getMeshNodeByIndex(i), xPos, yPos, columnWidth, heading, lat, lon); - } - - lastNodeY = std::max(lastNodeY, yPos + FONT_HEIGHT_SMALL); - yOffset += rowYOffset; - shownCount++; - rowCount++; - - if (rowCount >= totalRowsAvailable) { - yOffset = 0; - rowCount = 0; - col++; - if (col > (totalColumns - 1)) - break; - } - } - - // This should correct the scrollbar - totalEntries -= numskipped; - -#if !defined(M5STACK_UNITC6L) - // Draw column separator - if (shownCount > 0) { - const int firstNodeY = y + 3; - for (int horizontal_offset = 1; horizontal_offset < totalColumns; horizontal_offset++) { - drawColumnSeparator(display, columnWidth * horizontal_offset, firstNodeY, lastNodeY); - } - } - -#endif - const int scrollStartY = y + 3; - drawScrollbar(display, visibleNodeRows, totalEntries, scrollIndex, 2, scrollStartY); - graphics::drawCommonFooter(display, x, y); -} - -// ============================= -// Screen Frame Functions -// ============================= - -#ifndef USE_EINK -// Node list for Last Heard and Hop Signal views -void drawDynamicListScreen_Nodes(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - // Static variables to track mode and duration - static ListMode_Node lastRenderedMode = MODE_COUNT_NODE; - static unsigned long modeStartTime = 0; - - unsigned long now = millis(); - -#if defined(M5STACK_UNITC6L) - display->clear(); - if (now - lastSwitchTime >= 3000) { - display->display(); - lastSwitchTime = now; - } -#endif - // On very first call (on boot or state enter) - if (lastRenderedMode == MODE_COUNT_NODE) { - currentMode_Nodes = MODE_LAST_HEARD; - modeStartTime = now; - } - - // Time to switch to next mode? - if (now - modeStartTime >= getModeCycleIntervalMs()) { - currentMode_Nodes = static_cast((currentMode_Nodes + 1) % MODE_COUNT_NODE); - modeStartTime = now; - } - - // Render screen based on currentMode - const char *title = getCurrentModeTitle_Nodes(display->getWidth()); - drawNodeListScreen(display, state, x, y, title, drawEntryDynamic_Nodes); - - // Track the last mode to avoid reinitializing modeStartTime - lastRenderedMode = currentMode_Nodes; -} - -// Node list for Distance and Bearings views -void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - // Static variables to track mode and duration - static ListMode_Location lastRenderedMode = MODE_COUNT_LOCATION; - static unsigned long modeStartTime = 0; - - unsigned long now = millis(); - -#if defined(M5STACK_UNITC6L) - display->clear(); - if (now - lastSwitchTime >= 3000) { - display->display(); - lastSwitchTime = now; - } -#endif - // On very first call (on boot or state enter) - if (lastRenderedMode == MODE_COUNT_LOCATION) { - currentMode_Location = MODE_DISTANCE; - modeStartTime = now; - } - - // Time to switch to next mode? - if (now - modeStartTime >= getModeCycleIntervalMs()) { - currentMode_Location = static_cast((currentMode_Location + 1) % MODE_COUNT_LOCATION); - modeStartTime = now; - } - - // Render screen based on currentMode - const char *title = getCurrentModeTitle_Location(display->getWidth()); - - // Render screen based on currentMode_Location - if (currentMode_Location == MODE_DISTANCE) { - drawNodeListScreen(display, state, x, y, title, drawNodeDistance); - } else if (currentMode_Location == MODE_BEARING) { - drawNodeListWithCompasses(display, state, x, y); - } - - // Track the last mode to avoid reinitializing modeStartTime - lastRenderedMode = currentMode_Location; -} -#endif - -#ifdef USE_EINK -void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - const char *title = "Last Heard"; - drawNodeListScreen(display, state, x, y, title, drawEntryLastHeard); -} - -void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ -#ifdef USE_EINK - const char *title = "Hops/Sig"; -#else - - const char *title = "Hops/Signal"; -#endif - drawNodeListScreen(display, state, x, y, title, drawEntryHopSignal); -} -void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - const char *title = "Distance"; - drawNodeListScreen(display, state, x, y, title, drawNodeDistance); -} -#endif -void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - float heading = 0; - bool validHeading = false; - auto ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - double lat = DegD(ourNode->position.latitude_i); - double lon = DegD(ourNode->position.longitude_i); - -#if defined(M5STACK_UNITC6L) - display->clear(); - uint32_t now = millis(); - if (now - lastSwitchTime >= 2000) { - display->display(); - lastSwitchTime = now; - } -#endif - if (uiconfig.compass_mode != meshtastic_CompassMode_FREEZE_HEADING) { -#if HAS_GPS - if (screen->hasHeading()) { - heading = screen->getHeading(); // degrees - validHeading = true; - } else { - heading = screen->estimatedHeading(lat, lon); - validHeading = !isnan(heading); - } -#endif - - if (!validHeading) - return; - } - drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon); -} - -/// Draw a series of fields in a column, wrapping to multiple columns if needed -void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields) -{ - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - const char **f = fields; - int xo = x, yo = y; - while (*f) { - display->drawString(xo, yo, *f); - if ((display->getColor() == BLACK) && config.display.heading_bold) - display->drawString(xo + 1, yo, *f); - - display->setColor(WHITE); - yo += FONT_HEIGHT_SMALL; - if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) { - xo += SCREEN_WIDTH / 2; - yo = 0; - } - f++; - } -} - -} // namespace NodeListRenderer -} // namespace graphics -#endif \ No newline at end of file diff --git a/NodeListRenderer.h b/NodeListRenderer.h deleted file mode 100644 index 8b82fb1177c..00000000000 --- a/NodeListRenderer.h +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -#include "graphics/Screen.h" -#include "mesh/generated/meshtastic/mesh.pb.h" -#include -#include - -namespace graphics -{ - -/// Forward declarations -class Screen; - -/** - * @brief Node list and entry rendering functions - * - * Contains all functions related to drawing node lists and individual node entries - * including last heard, hop signal, distance, and compass views. - */ -namespace NodeListRenderer -{ -// Entry renderer function types -typedef void (*EntryRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int); -typedef void (*NodeExtrasRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int, float, double, double); - -// Node list mode enumeration for Last Heard and Hop Signal views -enum ListMode_Node { MODE_LAST_HEARD = 0, MODE_HOP_SIGNAL = 1, MODE_COUNT_NODE = 2 }; - -// Node list mode enumeration for Distance and Bearings views -enum ListMode_Location { MODE_DISTANCE = 0, MODE_BEARING = 1, MODE_COUNT_LOCATION = 2 }; - -// Main node list screen function -void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, - EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0, - double lon = 0); - -// Entry renderers -void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); -void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); -void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); -void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); -void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); - -// Extras renderers -void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading, - double userLat, double userLon); - -// Screen frame functions -void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); -void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); -void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); -void drawDynamicListScreen_Nodes(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); -void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); -void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - -// Utility functions -const char *getCurrentModeTitle_Nodes(int screenWidth); -const char *getCurrentModeTitle_Location(int screenWidth); -const char *getSafeNodeName(meshtastic_NodeInfoLite *node); -void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields); - -// Bitmap drawing function -void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display); - -} // namespace NodeListRenderer - -} // namespace graphics diff --git a/Screen.cpp b/Screen.cpp deleted file mode 100644 index 149dd9eacf8..00000000000 --- a/Screen.cpp +++ /dev/null @@ -1,1804 +0,0 @@ -/* -BaseUI - -Developed and Maintained By: -- Ronald Garcia (HarukiToreda) – Lead development and implementation. -- JasonP (Xaositek) – Screen layout and icon design, UI improvements and testing. -- TonyG (Tropho) – Project management, structural planning, and testing - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - -*/ -#include "Screen.h" -#include "NodeDB.h" -#include "PowerMon.h" -#include "Throttle.h" -#include "configuration.h" -#include "meshUtils.h" -#if HAS_SCREEN -#include - -#include "DisplayFormatters.h" -#include "TimeFormatters.h" -#include "draw/ClockRenderer.h" -#include "draw/DebugRenderer.h" -#include "draw/MenuHandler.h" -#include "draw/MessageRenderer.h" -#include "draw/NodeListRenderer.h" -#include "draw/NotificationRenderer.h" -#include "draw/UIRenderer.h" -#include "modules/CannedMessageModule.h" - -#if !MESHTASTIC_EXCLUDE_GPS -#include "GPS.h" -#include "buzz.h" -#endif -#include "FSCommon.h" -#include "MeshService.h" -#include "MessageStore.h" -#include "RadioLibInterface.h" -#include "error.h" -#include "gps/GeoCoord.h" -#include "gps/RTC.h" -#include "graphics/ScreenFonts.h" -#include "graphics/SharedUIDisplay.h" -#include "graphics/emotes.h" -#include "graphics/images.h" -#include "input/TouchScreenImpl1.h" -#include "main.h" -#include "mesh-pb-constants.h" -#include "mesh/Channels.h" -#include "mesh/generated/meshtastic/deviceonly.pb.h" -#include "modules/ExternalNotificationModule.h" -#include "modules/TextMessageModule.h" -#include "modules/WaypointModule.h" -#include "sleep.h" -#include "target_specific.h" -extern MessageStore messageStore; - -#if USE_TFTDISPLAY -extern uint16_t TFT_MESH; -#else -uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94); -#endif - -#if HAS_WIFI && !defined(ARCH_PORTDUINO) -#include "mesh/wifi/WiFiAPClient.h" -#endif - -#ifdef ARCH_ESP32 -#endif - -#if ARCH_PORTDUINO -#include "modules/StoreForwardModule.h" -#include "platform/portduino/PortduinoGlue.h" -#endif - -#if defined(T_LORA_PAGER) -// KB backlight control -#include "input/cardKbI2cImpl.h" -#endif - -using namespace meshtastic; /** @todo remove */ - -namespace graphics -{ - -// This means the *visible* area (sh1106 can address 132, but shows 128 for example) -#define IDLE_FRAMERATE 1 // in fps - -// DEBUG -#define NUM_EXTRA_FRAMES 3 // text message and debug frame -// if defined a pixel will blink to show redraws -// #define SHOW_REDRAWS -#define ASCII_BELL '\x07' -// A text message frame + debug frame + all the node infos -FrameCallback *normalFrames; -static uint32_t targetFramerate = IDLE_FRAMERATE; -// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization - -uint32_t logo_timeout = 5000; // 4 seconds for EACH logo - -// Threshold values for the GPS lock accuracy bar display -uint32_t dopThresholds[5] = {2000, 1000, 500, 200, 100}; - -// At some point, we're going to ask all of the modules if they would like to display a screen frame -// we'll need to hold onto pointers for the modules that can draw a frame. -std::vector moduleFrames; - -#if HAS_GPS -// GeoCoord object for the screen -GeoCoord geoCoord; -#endif - -#ifdef SHOW_REDRAWS -static bool heartbeat = false; -#endif - -#include "graphics/ScreenFonts.h" -#include - -// Usage: int stringWidth = formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display); -// End Functions to write date/time to the screen - -extern bool hasUnreadMessage; - -// ============================== -// Overlay Alert Banner Renderer -// ============================== -// Displays a temporary centered banner message (e.g., warning, status, etc.) -// The banner appears in the center of the screen and disappears after the specified duration - -void Screen::showSimpleBanner(const char *message, uint32_t durationMs) -{ - BannerOverlayOptions options; - options.message = message; - options.durationMs = durationMs; - options.notificationType = notificationTypeEnum::text_banner; - showOverlayBanner(options); -} - -// Called to trigger a banner with custom message and duration -void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) -{ -#ifdef USE_EINK - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus -#endif - // Store the message and set the expiration timestamp - strncpy(NotificationRenderer::alertBannerMessage, banner_overlay_options.message, 255); - NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination - NotificationRenderer::alertBannerUntil = - (banner_overlay_options.durationMs == 0) ? 0 : millis() + banner_overlay_options.durationMs; - NotificationRenderer::optionsArrayPtr = banner_overlay_options.optionsArrayPtr; - NotificationRenderer::optionsEnumPtr = banner_overlay_options.optionsEnumPtr; - NotificationRenderer::alertBannerOptions = banner_overlay_options.optionsCount; - NotificationRenderer::alertBannerCallback = banner_overlay_options.bannerCallback; - NotificationRenderer::curSelected = banner_overlay_options.InitialSelected; - NotificationRenderer::pauseBanner = false; - NotificationRenderer::current_notification_type = notificationTypeEnum::selection_picker; - static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - ui->setTargetFPS(60); - ui->update(); -} - -// Called to trigger a banner with custom message and duration -void Screen::showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback) -{ -#ifdef USE_EINK - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus -#endif - nodeDB->pause_sort(true); - // Store the message and set the expiration timestamp - strncpy(NotificationRenderer::alertBannerMessage, message, 255); - NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; - NotificationRenderer::alertBannerCallback = bannerCallback; - NotificationRenderer::pauseBanner = false; - NotificationRenderer::curSelected = 0; - NotificationRenderer::current_notification_type = notificationTypeEnum::node_picker; - - static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - ui->setTargetFPS(60); - ui->update(); -} - -// Called to trigger a banner with custom message and duration -void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, - std::function bannerCallback) -{ -#ifdef USE_EINK - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus -#endif - // Store the message and set the expiration timestamp - strncpy(NotificationRenderer::alertBannerMessage, message, 255); - NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; - NotificationRenderer::alertBannerCallback = bannerCallback; - NotificationRenderer::pauseBanner = false; - NotificationRenderer::curSelected = 0; - NotificationRenderer::current_notification_type = notificationTypeEnum::number_picker; - NotificationRenderer::numDigits = digits; - NotificationRenderer::currentNumber = 0; - - static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - ui->setTargetFPS(60); - ui->update(); -} - -void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, - std::function textCallback) -{ - LOG_INFO("showTextInput called with header='%s', durationMs=%d", header ? header : "NULL", durationMs); - - // Start OnScreenKeyboardModule session (non-touch variant) - OnScreenKeyboardModule::instance().start(header, initialText, durationMs, textCallback); - NotificationRenderer::textInputCallback = textCallback; - - // Store the message and set the expiration timestamp (use same pattern as other notifications) - strncpy(NotificationRenderer::alertBannerMessage, header ? header : "Text Input", 255); - NotificationRenderer::alertBannerMessage[255] = '\0'; - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; - NotificationRenderer::pauseBanner = false; - NotificationRenderer::current_notification_type = notificationTypeEnum::text_input; - - // Set the overlay using the same pattern as other notification types - static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - ui->setTargetFPS(60); - ui->update(); -} - -static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - uint8_t module_frame; - // there's a little but in the UI transition code - // where it invokes the function at the correct offset - // in the array of "drawScreen" functions; however, - // the passed-state doesn't quite reflect the "current" - // screen, so we have to detect it. - if (state->frameState == IN_TRANSITION && state->transitionFrameRelationship == TransitionRelationship_INCOMING) { - // if we're transitioning from the end of the frame list back around to the first - // frame, then we want this to be `0` - module_frame = state->transitionFrameTarget; - } else { - // otherwise, just display the module frame that's aligned with the current frame - module_frame = state->currentFrame; - } - MeshModule &pi = *moduleFrames.at(module_frame); - pi.drawFrame(display, state, x, y); -} - -/** - * Given a recent lat/lon return a guess of the heading the user is walking on. - * - * We keep a series of "after you've gone 10 meters, what is your heading since - * the last reference point?" - */ -float Screen::estimatedHeading(double lat, double lon) -{ - static double oldLat, oldLon; - static float b; - - if (oldLat == 0) { - // just prepare for next time - oldLat = lat; - oldLon = lon; - - return b; - } - - float d = GeoCoord::latLongToMeter(oldLat, oldLon, lat, lon); - if (d < 10) // haven't moved enough, just keep current bearing - return b; - - b = GeoCoord::bearing(oldLat, oldLon, lat, lon) * RAD_TO_DEG; - oldLat = lat; - oldLon = lon; - - return b; -} - -/// We will skip one node - the one for us, so we just blindly loop over all -/// nodes -static int8_t prevFrame = -1; - -// Combined dynamic node list frame cycling through LastHeard, HopSignal, and Distance modes -// Uses a single frame and changes data every few seconds (E-Ink variant is separate) - -#if defined(ESP_PLATFORM) && (defined(USE_ST7789) || defined(USE_ST7796)) -SPIClass SPI1(HSPI); -#endif - -Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_OledType screenType, OLEDDISPLAY_GEOMETRY geometry) - : concurrency::OSThread("Screen"), address_found(address), model(screenType), geometry(geometry), cmdQueue(32) -{ - graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES]; - - int32_t rawRGB = uiconfig.screen_rgb_color; - - // Only validate the combined value once - if (rawRGB > 0 && rawRGB <= 255255255) { - // Extract each component as a normal int first - int r = (rawRGB >> 16) & 0xFF; - int g = (rawRGB >> 8) & 0xFF; - int b = rawRGB & 0xFF; - if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { - TFT_MESH = COLOR565(static_cast(r), static_cast(g), static_cast(b)); - } - } - -#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64) - dispdev = new SH1106Wire(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); -#elif defined(USE_ST7789) -#ifdef ESP_PLATFORM - dispdev = new ST7789Spi(&SPI1, ST7789_RESET, ST7789_RS, ST7789_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT, ST7789_SDA, - ST7789_MISO, ST7789_SCK); -#else - dispdev = new ST7789Spi(&SPI1, ST7789_RESET, ST7789_RS, ST7789_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT); -#endif -#elif defined(USE_ST7796) -#ifdef ESP_PLATFORM - dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT, ST7796_SDA, - ST7796_MISO, ST7796_SCK, TFT_SPI_FREQUENCY); -#else - dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT); -#endif -#elif defined(USE_SSD1306) - dispdev = new SSD1306Wire(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); -#elif defined(USE_SPISSD1306) - dispdev = new SSD1306Spi(SSD1306_RESET, SSD1306_RS, SSD1306_NSS, GEOMETRY_64_48); - if (!dispdev->init()) { - LOG_DEBUG("Error: SSD1306 not detected!"); - } else { - static_cast(dispdev)->setHorizontalOffset(32); - LOG_INFO("SSD1306 init success"); - } -#elif defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7789_CS) || \ - defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR) - dispdev = new TFTDisplay(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); -#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) - dispdev = new EInkDisplay(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); -#elif defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY) - dispdev = new EInkDynamicDisplay(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); -#elif defined(USE_ST7567) - dispdev = new ST7567Wire(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); -#elif ARCH_PORTDUINO - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { - if (portduino_config.displayPanel != no_screen) { - LOG_DEBUG("Make TFTDisplay!"); - dispdev = new TFTDisplay(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); - } else { - dispdev = new AutoOLEDWire(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); - isAUTOOled = true; - } - } -#else - dispdev = new AutoOLEDWire(address.address, -1, -1, geometry, - (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); - isAUTOOled = true; -#endif - -#if defined(USE_ST7789) - static_cast(dispdev)->setRGB(TFT_MESH); -#elif defined(USE_ST7796) - static_cast(dispdev)->setRGB(TFT_MESH); -#endif - - ui = new OLEDDisplayUi(dispdev); - cmdQueue.setReader(this); -} - -Screen::~Screen() -{ - delete[] graphics::normalFrames; -} - -/** - * Prepare the display for the unit going to the lowest power mode possible. Most screens will just - * poweroff, but eink screens will show a "I'm sleeping" graphic, possibly with a QR code - */ -void Screen::doDeepSleep() -{ -#ifdef USE_EINK - setOn(false, graphics::UIRenderer::drawDeepSleepFrame); -#else - // Without E-Ink display: - setOn(false); -#endif -} - -void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) -{ - if (!useDisplay) - return; - - if (on != screenOn) { - if (on) { - LOG_INFO("Turn on screen"); - powerMon->setState(meshtastic_PowerMon_State_Screen_On); -#ifdef T_WATCH_S3 - PMU->enablePowerOutput(XPOWERS_ALDO2); -#endif - -#if defined(MUZI_BASE) - dispdev->init(); - dispdev->setBrightness(brightness); - dispdev->flipScreenVertically(); - dispdev->resetDisplay(); - digitalWrite(SCREEN_12V_ENABLE, HIGH); - delay(100); -#endif -#if !ARCH_PORTDUINO - dispdev->displayOn(); -#endif - -#ifdef PIN_EINK_EN - if (uiconfig.screen_brightness == 1) - digitalWrite(PIN_EINK_EN, HIGH); -#elif defined(PCA_PIN_EINK_EN) - if (uiconfig.screen_brightness > 0) - io.digitalWrite(PCA_PIN_EINK_EN, HIGH); -#endif - -#if defined(ST7789_CS) && \ - !defined(M5STACK) // set display brightness when turning on screens. Just moved function from TFTDisplay to here. - static_cast(dispdev)->setDisplayBrightness(brightness); -#endif - - dispdev->displayOn(); -#if defined(HELTEC_TRACKER_V1_X) || defined(HELTEC_WIRELESS_TRACKER_V2) - ui->init(); -#endif -#ifdef USE_ST7789 - pinMode(VTFT_CTRL, OUTPUT); - digitalWrite(VTFT_CTRL, LOW); - ui->init(); -#ifdef ESP_PLATFORM - analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT); -#else - pinMode(VTFT_LEDA, OUTPUT); - digitalWrite(VTFT_LEDA, TFT_BACKLIGHT_ON); -#endif -#endif -#ifdef USE_ST7796 - ui->init(); -#ifdef ESP_PLATFORM - analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT); -#else - pinMode(VTFT_LEDA, OUTPUT); - digitalWrite(VTFT_LEDA, TFT_BACKLIGHT_ON); -#endif -#endif - enabled = true; - setInterval(0); // Draw ASAP - runASAP = true; - } else { - powerMon->clearState(meshtastic_PowerMon_State_Screen_On); -#ifdef USE_EINK - // eInkScreensaver parameter is usually NULL (default argument), default frame used instead - setScreensaverFrames(einkScreensaver); -#endif - -#ifdef PIN_EINK_EN - digitalWrite(PIN_EINK_EN, LOW); -#elif defined(PCA_PIN_EINK_EN) - io.digitalWrite(PCA_PIN_EINK_EN, LOW); -#endif - - dispdev->displayOff(); - -#ifdef SCREEN_12V_ENABLE - digitalWrite(SCREEN_12V_ENABLE, LOW); -#endif -#ifdef USE_ST7789 - SPI1.end(); -#if defined(ARCH_ESP32) - pinMode(VTFT_LEDA, ANALOG); - pinMode(VTFT_CTRL, ANALOG); - pinMode(ST7789_RESET, ANALOG); - pinMode(ST7789_RS, ANALOG); - pinMode(ST7789_NSS, ANALOG); -#else - nrf_gpio_cfg_default(VTFT_LEDA); - nrf_gpio_cfg_default(VTFT_CTRL); - nrf_gpio_cfg_default(ST7789_RESET); - nrf_gpio_cfg_default(ST7789_RS); - nrf_gpio_cfg_default(ST7789_NSS); -#endif -#endif -#ifdef USE_ST7796 - SPI1.end(); -#if defined(ARCH_ESP32) - pinMode(VTFT_LEDA, OUTPUT); - digitalWrite(VTFT_LEDA, LOW); - pinMode(ST7796_RESET, ANALOG); - pinMode(ST7796_RS, ANALOG); - pinMode(ST7796_NSS, ANALOG); -#else - nrf_gpio_cfg_default(VTFT_LEDA); - nrf_gpio_cfg_default(ST7796_RESET); - nrf_gpio_cfg_default(ST7796_RS); - nrf_gpio_cfg_default(ST7796_NSS); -#endif -#endif - -#ifdef T_WATCH_S3 - PMU->disablePowerOutput(XPOWERS_ALDO2); -#endif - enabled = false; - } - screenOn = on; - } -} - -void Screen::setup() -{ - - // Enable display rendering - useDisplay = true; - - // Load saved brightness from UI config - // For OLED displays (SSD1306), default brightness is 255 if not set - if (uiconfig.screen_brightness == 0) { -#if defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) - brightness = 255; // Default for OLED -#else - brightness = BRIGHTNESS_DEFAULT; -#endif - } else { - brightness = uiconfig.screen_brightness; - } - - // Detect OLED subtype (if supported by board variant) -#ifdef AutoOLEDWire_h - if (isAUTOOled) - static_cast(dispdev)->setDetected(model); -#endif - -#if defined(USE_SH1107_128_64) || defined(USE_SH1107) - static_cast(dispdev)->setSubtype(7); -#endif - -#if defined(USE_ST7789) && defined(TFT_MESH) - // Apply custom RGB color (e.g. Heltec T114/T190) - static_cast(dispdev)->setRGB(TFT_MESH); -#endif -#if defined(MUZI_BASE) - dispdev->delayPoweron = true; -#endif -#if defined(USE_ST7796) && defined(TFT_MESH) - // Custom text color, if defined in variant.h - static_cast(dispdev)->setRGB(TFT_MESH); -#endif - - // Initialize display and UI system - ui->init(); - displayWidth = dispdev->width(); - displayHeight = dispdev->height(); - - ui->setTimePerTransition(0); // Disable animation delays - ui->setIndicatorPosition(BOTTOM); // Not used (indicators disabled below) - ui->setIndicatorDirection(LEFT_RIGHT); // Not used (indicators disabled below) - ui->setFrameAnimation(SLIDE_LEFT); // Used only when indicators are active - ui->disableAllIndicators(); // Disable page indicator dots - ui->getUiState()->userData = this; // Allow static callbacks to access Screen instance - - // Apply loaded brightness -#if defined(ST7789_CS) - static_cast(dispdev)->setDisplayBrightness(brightness); -#elif defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SPISSD1306) - dispdev->setBrightness(brightness); -#endif - LOG_INFO("Applied screen brightness: %d", brightness); - - // Set custom overlay callbacks - static OverlayCallback overlays[] = { - graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame - }; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - - // Enable UTF-8 to display mapping - dispdev->setFontTableLookupFunction(customFontTableLookup); - -#ifdef USERPREFS_OEM_TEXT - logo_timeout *= 2; // Give more time for branded boot logos -#endif - - // Configure alert frames (e.g., "Resuming..." or region name) - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip slow refresh - alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { -#ifdef ARCH_ESP32 - if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1) - graphics::UIRenderer::drawFrameText(display, state, x, y, "Resuming..."); - else -#endif - { - const char *region = myRegion ? myRegion->name : nullptr; - graphics::UIRenderer::drawIconScreen(region, display, state, x, y); - } - }; - ui->setFrames(alertFrames, 1); - ui->disableAutoTransition(); // Require manual navigation between frames - - // Log buffer for on-screen logs (3 lines max) - dispdev->setLogBuffer(3, 32); - - // Optional screen mirroring or flipping (e.g. for T-Beam orientation) -#ifdef SCREEN_MIRROR - dispdev->mirrorScreen(); -#else - if (!config.display.flip_screen) { -#if defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \ - defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR) - static_cast(dispdev)->flipScreenVertically(); -#elif defined(USE_ST7789) - static_cast(dispdev)->flipScreenVertically(); -#elif defined(USE_ST7796) - static_cast(dispdev)->mirrorScreen(); -#elif !defined(M5STACK_UNITC6L) - dispdev->flipScreenVertically(); -#endif - } -#endif - - // Generate device ID from MAC address - uint8_t dmac[6]; - getMacAddr(dmac); - snprintf(screen->ourId, sizeof(screen->ourId), "%02x%02x", dmac[4], dmac[5]); - -#if ARCH_PORTDUINO - handleSetOn(false); // Ensure proper init for Arduino targets -#endif - - // Turn on display and trigger first draw - handleSetOn(true); - determineResolution(dispdev->height(), dispdev->width()); - ui->update(); -#ifndef USE_EINK - ui->update(); // Some SSD1306 clones drop the first draw, so run twice -#endif - serialSinceMsec = millis(); - -#if ARCH_PORTDUINO - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { - if (portduino_config.touchscreenModule) { - touchScreenImpl1 = - new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch); - touchScreenImpl1->init(); - } - } -#elif HAS_TOUCHSCREEN && !defined(USE_EINK) && !HAS_CST226SE - touchScreenImpl1 = - new TouchScreenImpl1(dispdev->getWidth(), dispdev->getHeight(), static_cast(dispdev)->getTouch); - touchScreenImpl1->init(); -#endif - - // Subscribe to device status updates - powerStatusObserver.observe(&powerStatus->onNewStatus); - gpsStatusObserver.observe(&gpsStatus->onNewStatus); - nodeStatusObserver.observe(&nodeStatus->onNewStatus); - -#if !MESHTASTIC_EXCLUDE_ADMIN - adminMessageObserver.observe(adminModule); -#endif - if (inputBroker) - inputObserver.observe(inputBroker); - - // Load persisted messages into RAM - messageStore.loadFromFlash(); - LOG_INFO("MessageStore loaded from flash"); - - // Notify modules that support UI events - MeshModule::observeUIEvents(&uiFrameEventObserver); -} - -void Screen::setOn(bool on, FrameCallback einkScreensaver) -{ -#if defined(T_LORA_PAGER) - if (cardKbI2cImpl) - cardKbI2cImpl->toggleBacklight(on); -#endif - if (!on) - // We handle off commands immediately, because they might be called because the CPU is shutting down - handleSetOn(false, einkScreensaver); - else - enqueueCmd(ScreenCmd{.cmd = Cmd::SET_ON}); -} - -void Screen::forceDisplay(bool forceUiUpdate) -{ - // Nasty hack to force epaper updates for 'key' frames. FIXME, cleanup. -#ifdef USE_EINK - // If requested, make sure queued commands are run, and UI has rendered a new frame - if (forceUiUpdate) { - // Force a display refresh, in addition to the UI update - // Changing the GPS status bar icon apparently doesn't register as a change in image - // (False negative of the image hashing algorithm used to skip identical frames) - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); - - // No delay between UI frame rendering - setFastFramerate(); - - // Make sure all CMDs have run first - while (!cmdQueue.isEmpty()) - runOnce(); - - // Ensure at least one frame has drawn - uint64_t startUpdate; - do { - startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow.. - delay(10); - ui->update(); - } while (ui->getUiState()->lastUpdate < startUpdate); - - // Return to normal frame rate - targetFramerate = IDLE_FRAMERATE; - ui->setTargetFPS(targetFramerate); - } - - // Tell EInk class to update the display - static_cast(dispdev)->forceDisplay(); -#else - // No delay between UI frame rendering - if (forceUiUpdate) { - setFastFramerate(); - } -#endif -} - -static uint32_t lastScreenTransition; - -int32_t Screen::runOnce() -{ - // If we don't have a screen, don't ever spend any CPU for us. - if (!useDisplay) { - enabled = false; - return RUN_SAME; - } - - if (displayHeight == 0) { - displayHeight = dispdev->getHeight(); - } - - // Detect frame transitions and clear message cache when leaving text message screen - { - static int8_t lastFrameIndex = -1; - int8_t currentFrameIndex = ui->getUiState()->currentFrame; - int8_t textMsgIndex = framesetInfo.positions.textMessage; - - if (lastFrameIndex != -1 && currentFrameIndex != lastFrameIndex) { - - if (lastFrameIndex == textMsgIndex && currentFrameIndex != textMsgIndex) { - graphics::MessageRenderer::clearMessageCache(); - } - } - - lastFrameIndex = currentFrameIndex; - } - - menuHandler::handleMenuSwitch(dispdev); - - // Show boot screen for first logo_timeout seconds, then switch to normal operation. - // serialSinceMsec adjusts for additional serial wait time during nRF52 bootup - static bool showingBootScreen = true; - if (showingBootScreen && (millis() > (logo_timeout + serialSinceMsec))) { - LOG_INFO("Done with boot screen"); - stopBootScreen(); - showingBootScreen = false; - } - -#ifdef USERPREFS_OEM_TEXT - static bool showingOEMBootScreen = true; - if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) { - LOG_INFO("Switch to OEM screen..."); - // Change frames. - static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen}; - static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]); - ui->setFrames(bootOEMFrames, bootOEMFrameCount); - ui->update(); -#ifndef USE_EINK - ui->update(); -#endif - showingOEMBootScreen = false; - } -#endif - -#ifndef DISABLE_WELCOME_UNSET - if (!NotificationRenderer::isOverlayBannerShowing() && config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { -#if defined(M5STACK_UNITC6L) - menuHandler::LoraRegionPicker(); -#else - menuHandler::OnboardMessage(); -#endif - } -#endif - if (!NotificationRenderer::isOverlayBannerShowing() && rebootAtMsec != 0) { - showSimpleBanner("Rebooting...", 0); - } - - // Process incoming commands. - for (;;) { - ScreenCmd cmd; - if (!cmdQueue.dequeue(&cmd, 0)) { - break; - } - switch (cmd.cmd) { - case Cmd::SET_ON: - handleSetOn(true); - break; - case Cmd::SET_OFF: - handleSetOn(false); - break; - case Cmd::ON_PRESS: - if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { - showFrame(FrameDirection::NEXT); - } - break; - case Cmd::SHOW_PREV_FRAME: - if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { - showFrame(FrameDirection::PREVIOUS); - } - break; - case Cmd::SHOW_NEXT_FRAME: - if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { - showFrame(FrameDirection::NEXT); - } - break; - case Cmd::START_ALERT_FRAME: { - showingBootScreen = false; // this should avoid the edge case where an alert triggers before the boot screen goes away - showingNormalScreen = false; - NotificationRenderer::pauseBanner = true; - alertFrames[0] = alertFrame; -#ifdef USE_EINK - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please - EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update - handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?) -#endif - setFrameImmediateDraw(alertFrames); - break; - } - case Cmd::START_FIRMWARE_UPDATE_SCREEN: - handleStartFirmwareUpdateScreen(); - break; - case Cmd::STOP_ALERT_FRAME: - NotificationRenderer::pauseBanner = false; - break; - case Cmd::STOP_BOOT_SCREEN: - EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame - if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { - setFrames(); - } - break; - case Cmd::NOOP: - break; - default: - LOG_ERROR("Invalid screen cmd"); - } - } - - if (!screenOn) { // If we didn't just wake and the screen is still off, then - // stop updating until it is on again - enabled = false; - return 0; - } - - // this must be before the frameState == FIXED check, because we always - // want to draw at least one FIXED frame before doing forceDisplay - ui->update(); - - // Switch to a low framerate (to save CPU) when we are not in transition - // but we should only call setTargetFPS when framestate changes, because - // otherwise that breaks animations. - - if (targetFramerate != IDLE_FRAMERATE && ui->getUiState()->frameState == FIXED) { - // oldFrameState = ui->getUiState()->frameState; - targetFramerate = IDLE_FRAMERATE; - - ui->setTargetFPS(targetFramerate); - forceDisplay(); - } - - // While showing the bootscreen or Bluetooth pair screen all of our - // standard screen switching is stopped. - if (showingNormalScreen) { - // standard screen loop handling here - if (config.display.auto_screen_carousel_secs > 0 && - NotificationRenderer::current_notification_type != notificationTypeEnum::text_input && - !Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) { - - // If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead - // Carousel is potentially a major source of E-Ink display wear -#if !defined(EINK_BACKGROUND_USES_FAST) - EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); -#endif - - LOG_DEBUG("LastScreenTransition exceeded %ums transition to next frame", (millis() - lastScreenTransition)); - handleOnPress(); - } - } - - // LOG_DEBUG("want fps %d, fixed=%d", targetFramerate, - // ui->getUiState()->frameState); If we are scrolling we need to be called - // soon, otherwise just 1 fps (to save CPU) We also ask to be called twice - // as fast as we really need so that any rounding errors still result with - // the correct framerate - return (1000 / targetFramerate); -} - -/* show a message that the SSL cert is being built - * it is expected that this will be used during the boot phase */ -void Screen::setSSLFrames() -{ - if (address_found.address) { - // LOG_DEBUG("Show SSL frames"); - static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen}; - ui->setFrames(sslFrames, 1); - ui->update(); - } -} - -#ifdef USE_EINK -/// Determine which screensaver frame to use, then set the FrameCallback -void Screen::setScreensaverFrames(FrameCallback einkScreensaver) -{ - // Retain specified frame / overlay callback beyond scope of this method - static FrameCallback screensaverFrame; - static OverlayCallback screensaverOverlay; - -#if defined(HAS_EINK_ASYNCFULL) && defined(USE_EINK_DYNAMICDISPLAY) - // Join (await) a currently running async refresh, then run the post-update code. - // Avoid skipping of screensaver frame. Would otherwise be handled by NotifiedWorkerThread. - EINK_JOIN_ASYNCREFRESH(dispdev); -#endif - - // If: one-off screensaver frame passed as argument. Handles doDeepSleep() - if (einkScreensaver != NULL) { - screensaverFrame = einkScreensaver; - ui->setFrames(&screensaverFrame, 1); - } - - // Else, display the usual "overlay" screensaver - else { - screensaverOverlay = graphics::UIRenderer::drawScreensaverOverlay; - ui->setOverlays(&screensaverOverlay, 1); - } - - // Request new frame, ASAP - setFastFramerate(); - uint64_t startUpdate; - do { - startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow.. - delay(1); - ui->update(); - } while (ui->getUiState()->lastUpdate < startUpdate); - - // Old EInkDisplay class -#if !defined(USE_EINK_DYNAMICDISPLAY) - static_cast(dispdev)->forceDisplay(0); // Screen::forceDisplay(), but override rate-limit -#endif - - // Prepare now for next frame, shown when display wakes - ui->setOverlays(NULL, 0); // Clear overlay - setFrames(FOCUS_PRESERVE); // Return to normal display updates, showing same frame as before screensaver, ideally - - // Pick a refresh method, for when display wakes -#ifdef EINK_HASQUIRK_GHOSTING - EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // Really ugly to see ghosting from "screen paused" -#else - EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh -#endif -} -#endif - -// Regenerate the normal set of frames, focusing a specific frame if requested -// Called when a frame should be added / removed, or custom frames should be cleared -void Screen::setFrames(FrameFocus focus) -{ - // Block setFrames calls when virtual keyboard is active to prevent overlay interference - if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { - return; - } - - uint8_t originalPosition = ui->getUiState()->currentFrame; - uint8_t previousFrameCount = framesetInfo.frameCount; - FramesetInfo fsi; // Location of specific frames, for applying focus parameter - - graphics::UIRenderer::rebuildFavoritedNodes(); - - LOG_DEBUG("Show standard frames"); - showingNormalScreen = true; - - indicatorIcons.clear(); - - size_t numframes = 0; - - // If we have a critical fault, show it first - fsi.positions.fault = numframes; - if (error_code) { - normalFrames[numframes++] = NotificationRenderer::drawCriticalFaultFrame; - indicatorIcons.push_back(icon_error); - focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame - } - -#if defined(DISPLAY_CLOCK_FRAME) - if (!hiddenFrames.clock) { - fsi.positions.clock = numframes; -#if defined(M5STACK_UNITC6L) - normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame; -#else - normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame - : graphics::ClockRenderer::drawDigitalClockFrame; -#endif - indicatorIcons.push_back(digital_icon_clock); - } -#endif - - if (!hiddenFrames.home) { - fsi.positions.home = numframes; - normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused; - indicatorIcons.push_back(icon_home); - } - - fsi.positions.textMessage = numframes; - normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame; - indicatorIcons.push_back(icon_mail); - -#ifndef USE_EINK - if (!hiddenFrames.nodelist_nodes) { - fsi.positions.nodelist_nodes = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Nodes; - indicatorIcons.push_back(icon_nodes); - } - if (!hiddenFrames.nodelist_location) { - fsi.positions.nodelist_location = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Location; - indicatorIcons.push_back(icon_list); - } -#endif - -// Show detailed node views only on E-Ink builds -#ifdef USE_EINK - if (!hiddenFrames.nodelist_lastheard) { - fsi.positions.nodelist_lastheard = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen; - indicatorIcons.push_back(icon_nodes); - } - if (!hiddenFrames.nodelist_hopsignal) { - fsi.positions.nodelist_hopsignal = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen; - indicatorIcons.push_back(icon_signal); - } - if (!hiddenFrames.nodelist_distance) { - fsi.positions.nodelist_distance = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen; - indicatorIcons.push_back(icon_distance); - } -#endif -#if HAS_GPS -#ifdef USE_EINK - if (!hiddenFrames.nodelist_bearings) { - fsi.positions.nodelist_bearings = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; - indicatorIcons.push_back(icon_list); - } -#endif - if (!hiddenFrames.gps) { - fsi.positions.gps = numframes; - normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen; - indicatorIcons.push_back(icon_compass); - } -#endif - if (RadioLibInterface::instance && !hiddenFrames.lora) { - fsi.positions.lora = numframes; - normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused; - indicatorIcons.push_back(icon_radio); - } - if (!hiddenFrames.system) { - fsi.positions.system = numframes; - normalFrames[numframes++] = graphics::DebugRenderer::drawSystemScreen; - indicatorIcons.push_back(icon_system); - } -#if !defined(DISPLAY_CLOCK_FRAME) - if (!hiddenFrames.clock) { - fsi.positions.clock = numframes; - normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame - : graphics::ClockRenderer::drawDigitalClockFrame; - indicatorIcons.push_back(digital_icon_clock); - } -#endif - if (!hiddenFrames.chirpy) { - fsi.positions.chirpy = numframes; - normalFrames[numframes++] = graphics::DebugRenderer::drawChirpy; - indicatorIcons.push_back(chirpy_small); - } - -#if HAS_WIFI && !defined(ARCH_PORTDUINO) - if (!hiddenFrames.wifi && isWifiAvailable()) { - fsi.positions.wifi = numframes; - normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoWiFiTrampoline; - indicatorIcons.push_back(icon_wifi); - } -#endif - - // Beware of what changes you make in this code! - // We pass numframes into GetMeshModulesWithUIFrames() which is highly important! - // Inside of that callback, goes over to MeshModule.cpp and we run - // modulesWithUIFrames.resize(startIndex, nullptr), to insert nullptr - // entries until we're ready to start building the matching entries. - // We are doing our best to keep the normalFrames vector - // and the moduleFrames vector in lock step. - moduleFrames = MeshModule::GetMeshModulesWithUIFrames(numframes); - LOG_DEBUG("Show %d module frames", moduleFrames.size()); - - for (auto i = moduleFrames.begin(); i != moduleFrames.end(); ++i) { - // Draw the module frame, using the hack described above - if (*i != nullptr) { - normalFrames[numframes] = drawModuleFrame; - - // Check if the module being drawn has requested focus - // We will honor this request later, if setFrames was triggered by a UIFrameEvent - MeshModule *m = *i; - if (m && m->isRequestingFocus()) - fsi.positions.focusedModule = numframes; - if (m && m == waypointModule) - fsi.positions.waypoint = numframes; - - indicatorIcons.push_back(icon_module); - numframes++; - } - } - - LOG_DEBUG("Added modules. numframes: %d", numframes); - - // We don't show the node info of our node (if we have it yet - we should) - size_t numMeshNodes = nodeDB->getNumMeshNodes(); - if (numMeshNodes > 0) - numMeshNodes--; - - if (!hiddenFrames.show_favorites) { - // Temporary array to hold favorite node frames - std::vector favoriteFrames; - - for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { - const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); - if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) { - favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo); - } - } - - // Insert favorite frames *after* collecting them all - if (!favoriteFrames.empty()) { - fsi.positions.firstFavorite = numframes; - for (const auto &f : favoriteFrames) { - normalFrames[numframes++] = f; - indicatorIcons.push_back(icon_node); - } - fsi.positions.lastFavorite = numframes - 1; - } else { - fsi.positions.firstFavorite = 255; - fsi.positions.lastFavorite = 255; - } - } - - fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE - this->frameCount = numframes; // Save frame count for use in custom overlay - LOG_DEBUG("Finished build frames. numframes: %d", numframes); - - ui->setFrames(normalFrames, numframes); - ui->disableAllIndicators(); - - // Add overlays: frame icons and alert banner) - static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - - prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list just changed) - - // Focus on a specific frame, in the frame set we just created - switch (focus) { - case FOCUS_DEFAULT: - ui->switchToFrame(fsi.positions.deviceFocused); - break; - case FOCUS_FAULT: - ui->switchToFrame(fsi.positions.fault); - break; - case FOCUS_MODULE: - // Whichever frame was marked by MeshModule::requestFocus(), if any - // If no module requested focus, will show the first frame instead - ui->switchToFrame(fsi.positions.focusedModule); - break; - case FOCUS_CLOCK: - // Whichever frame was marked by MeshModule::requestFocus(), if any - // If no module requested focus, will show the first frame instead - ui->switchToFrame(fsi.positions.clock); - break; - case FOCUS_SYSTEM: - ui->switchToFrame(fsi.positions.system); - break; - - case FOCUS_PRESERVE: - // No more adjustment — force stay on same index - if (previousFrameCount > fsi.frameCount) { - ui->switchToFrame(originalPosition - 1); - } else if (previousFrameCount < fsi.frameCount) { - ui->switchToFrame(originalPosition + 1); - } else { - ui->switchToFrame(originalPosition); - } - break; - } - - // Store the info about this frameset, for future setFrames calls - this->framesetInfo = fsi; - - setFastFramerate(); // Draw ASAP -} - -void Screen::setFrameImmediateDraw(FrameCallback *drawFrames) -{ - ui->disableAllIndicators(); - ui->setFrames(drawFrames, 1); - setFastFramerate(); -} - -void Screen::toggleFrameVisibility(const std::string &frameName) -{ -#ifndef USE_EINK - if (frameName == "nodelist_nodes") { - hiddenFrames.nodelist_nodes = !hiddenFrames.nodelist_nodes; - } - if (frameName == "nodelist_location") { - hiddenFrames.nodelist_location = !hiddenFrames.nodelist_location; - } -#endif -#ifdef USE_EINK - if (frameName == "nodelist_lastheard") { - hiddenFrames.nodelist_lastheard = !hiddenFrames.nodelist_lastheard; - } - if (frameName == "nodelist_hopsignal") { - hiddenFrames.nodelist_hopsignal = !hiddenFrames.nodelist_hopsignal; - } - if (frameName == "nodelist_distance") { - hiddenFrames.nodelist_distance = !hiddenFrames.nodelist_distance; - } -#endif -#if HAS_GPS -#ifdef USE_EINK - if (frameName == "nodelist_bearings") { - hiddenFrames.nodelist_bearings = !hiddenFrames.nodelist_bearings; - } -#endif - if (frameName == "gps") { - hiddenFrames.gps = !hiddenFrames.gps; - } -#endif - if (frameName == "lora") { - hiddenFrames.lora = !hiddenFrames.lora; - } - if (frameName == "clock") { - hiddenFrames.clock = !hiddenFrames.clock; - } - if (frameName == "show_favorites") { - hiddenFrames.show_favorites = !hiddenFrames.show_favorites; - } - if (frameName == "chirpy") { - hiddenFrames.chirpy = !hiddenFrames.chirpy; - } -} - -bool Screen::isFrameHidden(const std::string &frameName) const -{ -#ifndef USE_EINK - if (frameName == "nodelist_nodes") - return hiddenFrames.nodelist_nodes; - if (frameName == "nodelist_location") - return hiddenFrames.nodelist_location; -#endif -#ifdef USE_EINK - if (frameName == "nodelist_lastheard") - return hiddenFrames.nodelist_lastheard; - if (frameName == "nodelist_hopsignal") - return hiddenFrames.nodelist_hopsignal; - if (frameName == "nodelist_distance") - return hiddenFrames.nodelist_distance; -#endif -#if HAS_GPS -#ifdef USE_EINK - if (frameName == "nodelist_bearings") - return hiddenFrames.nodelist_bearings; -#endif - if (frameName == "gps") - return hiddenFrames.gps; -#endif - if (frameName == "lora") - return hiddenFrames.lora; - if (frameName == "clock") - return hiddenFrames.clock; - if (frameName == "show_favorites") - return hiddenFrames.show_favorites; - if (frameName == "chirpy") - return hiddenFrames.chirpy; - - return false; -} - -void Screen::handleStartFirmwareUpdateScreen() -{ - LOG_DEBUG("Show firmware screen"); - showingNormalScreen = false; - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // E-Ink: Explicitly use fast-refresh for next frame - - static FrameCallback frames[] = {graphics::NotificationRenderer::drawFrameFirmware}; - setFrameImmediateDraw(frames); -} - -void Screen::blink() -{ - setFastFramerate(); - uint8_t count = 10; - dispdev->setBrightness(254); - while (count > 0) { - dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight()); - dispdev->display(); - delay(50); - dispdev->clear(); - dispdev->display(); - delay(50); - count = count - 1; - } - // The dispdev->setBrightness does not work for t-deck display, it seems to run the setBrightness function in - // OLEDDisplay. - dispdev->setBrightness(brightness); -} - -void Screen::increaseBrightness() -{ - brightness = ((brightness + 62) > 254) ? brightness : (brightness + 62); - -#if defined(ST7789_CS) - // run the setDisplayBrightness function. This works on t-decks - static_cast(dispdev)->setDisplayBrightness(brightness); -#endif - - /* TO DO: add little popup in center of screen saying what brightness level it is set to*/ -} - -void Screen::decreaseBrightness() -{ - brightness = (brightness < 70) ? brightness : (brightness - 62); - -#if defined(ST7789_CS) - static_cast(dispdev)->setDisplayBrightness(brightness); -#endif - - /* TO DO: add little popup in center of screen saying what brightness level it is set to*/ -} - -void Screen::handleOnPress() -{ - // If screen was off, just wake it, otherwise advance to next frame - // If we are in a transition, the press must have bounced, drop it. - if (ui->getUiState()->frameState == FIXED) { - ui->nextFrame(); - lastScreenTransition = millis(); - setFastFramerate(); - } -} - -void Screen::showFrame(FrameDirection direction) -{ - // Only advance frames when UI is stable - if (ui->getUiState()->frameState == FIXED) { - - if (direction == FrameDirection::NEXT) { - ui->nextFrame(); - } else { - ui->previousFrame(); - } - - lastScreenTransition = millis(); - setFastFramerate(); - } -} - -#ifndef SCREEN_TRANSITION_FRAMERATE -#define SCREEN_TRANSITION_FRAMERATE 30 // fps -#endif - -void Screen::setFastFramerate() -{ -#if defined(M5STACK_UNITC6L) - dispdev->clear(); - dispdev->display(); -#endif - // We are about to start a transition so speed up fps - targetFramerate = SCREEN_TRANSITION_FRAMERATE; - - ui->setTargetFPS(targetFramerate); - setInterval(0); // redraw ASAP - runASAP = true; -} - -int Screen::handleStatusUpdate(const meshtastic::Status *arg) -{ - switch (arg->getStatusType()) { - case STATUS_TYPE_NODE: - if (showingNormalScreen && nodeStatus->getLastNumTotal() != nodeStatus->getNumTotal()) { - setFrames(FOCUS_PRESERVE); // Regen the list of screen frames (returning to same frame, if possible) - } - nodeDB->updateGUI = false; - break; - case STATUS_TYPE_POWER: - forceDisplay(true); - break; - } - - return 0; -} - -// Handles when message is received; will jump to text message frame. -int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) -{ - if (showingNormalScreen) { - if (packet->from == 0) { - // Outgoing message (likely sent from phone) - devicestate.has_rx_text_message = false; - memset(&devicestate.rx_text_message, 0, sizeof(devicestate.rx_text_message)); - hiddenFrames.textMessage = true; - hasUnreadMessage = false; // Clear unread state when user replies - - setFrames(FOCUS_PRESERVE); // Stay on same frame, silently update frame list - } else { - // Incoming message - devicestate.has_rx_text_message = true; // Needed to include the message frame - hasUnreadMessage = true; // Enables mail icon in the header - setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view (no-op during text_input) - - // Only wake/force display if the configuration allows it - if (shouldWakeOnReceivedMessage()) { - setOn(true); // Wake up the screen first - forceDisplay(); // Forces screen redraw - } - // === Prepare banner/popup content === - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from); - const meshtastic_Channel channel = - channels.getByIndex(packet->channel ? packet->channel : channels.getPrimaryIndex()); - const char *longName = (node && node->has_user) ? node->user.long_name : nullptr; - - const char *msgRaw = reinterpret_cast(packet->decoded.payload.bytes); - - char banner[256]; - - bool isAlert = false; - - if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra || - moduleConfig.external_notification.alert_bell_buzzer) - // Check for bell character to determine if this message is an alert - for (size_t i = 0; i < packet->decoded.payload.size && i < 100; i++) { - if (msgRaw[i] == ASCII_BELL) { - isAlert = true; - break; - } - } - - // Unlike generic messages, alerts (when enabled via the ext notif module) ignore any - // 'mute' preferences set to any specific node or channel. - // If on-screen keyboard is active, show a transient popup over keyboard instead of interrupting it - if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { - // Wake and force redraw so popup is visible immediately - if (shouldWakeOnReceivedMessage()) { - setOn(true); - forceDisplay(); - } - - // Build popup: title = message source name, content = message text (sanitized) - // Title - char titleBuf[64] = {0}; - if (longName && longName[0]) { - // Sanitize sender name - std::string t = sanitizeString(longName); - strncpy(titleBuf, t.c_str(), sizeof(titleBuf) - 1); - } else { - strncpy(titleBuf, "Message", sizeof(titleBuf) - 1); - } - - // Content: payload bytes may not be null-terminated, remove ASCII_BELL and sanitize - char content[256] = {0}; - { - std::string raw; - raw.reserve(packet->decoded.payload.size); - for (size_t i = 0; i < packet->decoded.payload.size; ++i) { - char c = msgRaw[i]; - if (c == ASCII_BELL) - continue; // strip bell - raw.push_back(c); - } - std::string sanitized = sanitizeString(raw); - strncpy(content, sanitized.c_str(), sizeof(content) - 1); - } - - NotificationRenderer::showKeyboardMessagePopupWithTitle(titleBuf, content, 3000); - -// Maintain existing buzzer behavior on M5 if applicable -#if defined(M5STACK_UNITC6L) - if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY || - (isAlert && moduleConfig.external_notification.alert_bell_buzzer) || - (!isBroadcast(packet->to) && isToUs(packet))) { - playLongBeep(); - } -#endif - } else { - // No keyboard active: use regular banner flow, respecting mute settings - if (isAlert) { - if (longName && longName[0]) { - snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName); - } else { - strcpy(banner, "Alert Received"); - } - screen->showSimpleBanner(banner, 3000); - } else if (!channel.settings.has_module_settings || !channel.settings.module_settings.is_muted) { - if (longName && longName[0]) { -#if defined(M5STACK_UNITC6L) - strcpy(banner, "New Message"); -#else - snprintf(banner, sizeof(banner), "New Message from\n%s", longName); -#endif - } else { - strcpy(banner, "New Message"); - } -#if defined(M5STACK_UNITC6L) - screen->setOn(true); - screen->showSimpleBanner(banner, 1500); - if (config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY || - (isAlert && moduleConfig.external_notification.alert_bell_buzzer) || - (!isBroadcast(packet->to) && isToUs(packet))) { - // Beep if not in DIRECT_MSG_ONLY mode or if in DIRECT_MSG_ONLY mode and either - // - packet contains an alert and alert bell buzzer is enabled - // - packet is a non-broadcast that is addressed to this node - playLongBeep(); - } -#else - screen->showSimpleBanner(banner, 3000); -#endif - } - } - } - } - - return 0; -} - -// Triggered by MeshModules -int Screen::handleUIFrameEvent(const UIFrameEvent *event) -{ - // Block UI frame events when virtual keyboard is active - if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { - return 0; - } - - if (showingNormalScreen) { - // Regenerate the frameset, potentially honoring a module's internal requestFocus() call - if (event->action == UIFrameEvent::Action::REGENERATE_FRAMESET) { - setFrames(FOCUS_MODULE); - } - - // Regenerate the frameset, while attempting to maintain focus on the current frame - else if (event->action == UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND) { - setFrames(FOCUS_PRESERVE); - } - - // Don't regenerate the frameset, just re-draw whatever is on screen ASAP - else if (event->action == UIFrameEvent::Action::REDRAW_ONLY) { - setFastFramerate(); - } - - // Jump directly to the Text Message screen - else if (event->action == UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE) { - setFrames(FOCUS_PRESERVE); // preserve current frame ordering - ui->switchToFrame(framesetInfo.positions.textMessage); - setFastFramerate(); // force redraw ASAP - } - } - - return 0; -} - -int Screen::handleInputEvent(const InputEvent *event) -{ - LOG_INPUT("Screen Input event %u! kb %u", event->inputEvent, event->kbchar); - if (!screenOn) - return 0; - - // Handle text input notifications specially - pass input to virtual keyboard - if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { - NotificationRenderer::inEvent = *event; - static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - setFastFramerate(); // Draw ASAP - ui->update(); - return 0; - } - -#ifdef USE_EINK // the screen is the last input handler, so if an event makes it here, we can assume it will prompt a screen draw. - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please - EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update - handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?) - setFastFramerate(); // Draw ASAP -#endif - if (NotificationRenderer::isOverlayBannerShowing()) { - NotificationRenderer::inEvent = *event; - static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; - ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - setFastFramerate(); // Draw ASAP - ui->update(); - - menuHandler::handleMenuSwitch(dispdev); - return 0; - } - // UP/DOWN in message screen scrolls through message threads - if (ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { - - if (event->inputEvent == INPUT_BROKER_UP) { - if (messageStore.getMessages().empty()) { - cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); - } else { - graphics::MessageRenderer::scrollUp(); - setFastFramerate(); // match existing behavior - return 0; - } - } - - if (event->inputEvent == INPUT_BROKER_DOWN) { - if (messageStore.getMessages().empty()) { - cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); - } else { - graphics::MessageRenderer::scrollDown(); - setFastFramerate(); - return 0; - } - } - } - // Use left or right input from a keyboard to move between frames, - // so long as a mesh module isn't using these events for some other purpose - if (showingNormalScreen) { - - // Ask any MeshModules if they're handling keyboard input right now - bool inputIntercepted = false; - for (MeshModule *module : moduleFrames) { - if (module && module->interceptingKeyboardInput()) - inputIntercepted = true; - } - - // If no modules are using the input, move between frames - if (!inputIntercepted) { -#if defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2 - bool handledEncoderScroll = false; - const bool isTextMessageFrame = (framesetInfo.positions.textMessage != 255 && - this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && - !messageStore.getMessages().empty()); - if (isTextMessageFrame) { - if (event->inputEvent == INPUT_BROKER_UP_LONG) { - graphics::MessageRenderer::nudgeScroll(-1); - handledEncoderScroll = true; - } else if (event->inputEvent == INPUT_BROKER_DOWN_LONG) { - graphics::MessageRenderer::nudgeScroll(1); - handledEncoderScroll = true; - } - } - - if (handledEncoderScroll) { - setFastFramerate(); - return 0; - } -#endif - if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_ALT_PRESS) { - showFrame(FrameDirection::PREVIOUS); - } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS) { - showFrame(FrameDirection::NEXT); - } else if (event->inputEvent == INPUT_BROKER_UP_LONG) { - // Long press up button for fast frame switching - showPrevFrame(); - } else if (event->inputEvent == INPUT_BROKER_DOWN_LONG) { - // Long press down button for fast frame switching - showNextFrame(); - } else if ((event->inputEvent == INPUT_BROKER_UP || event->inputEvent == INPUT_BROKER_DOWN) && - this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { - cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); - } else if (event->inputEvent == INPUT_BROKER_SELECT) { - if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { - menuHandler::homeBaseMenu(); - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.system) { - menuHandler::systemBaseMenu(); -#if HAS_GPS - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.gps && gps) { - menuHandler::positionBaseMenu(); -#endif - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.clock) { - menuHandler::clockMenu(); - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) { - menuHandler::loraMenu(); - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { - if (!messageStore.getMessages().empty()) { - menuHandler::messageResponseMenu(); - } else { -#if defined(M5STACK_UNITC6L) - menuHandler::textMessageMenu(); -#else - menuHandler::textMessageBaseMenu(); -#endif - } - } else if (framesetInfo.positions.firstFavorite != 255 && - this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && - this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { - menuHandler::favoriteBaseMenu(); - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_nodes || - this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_location || - this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_lastheard || - this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_hopsignal || - this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_distance || - this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_hopsignal || - this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_bearings) { - menuHandler::nodeListMenu(); - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.wifi) { - menuHandler::wifiBaseMenu(); - } - } else if (event->inputEvent == INPUT_BROKER_BACK) { - showFrame(FrameDirection::PREVIOUS); - } else if (event->inputEvent == INPUT_BROKER_CANCEL) { - setOn(false); - } - } - } - - return 0; -} - -int Screen::handleAdminMessage(AdminModule_ObserverData *arg) -{ - switch (arg->request->which_payload_variant) { - // Node removed manually (i.e. via app) - case meshtastic_AdminMessage_remove_by_nodenum_tag: - setFrames(FOCUS_PRESERVE); - *arg->result = AdminMessageHandleResult::HANDLED; - break; - - // Default no-op, in case the admin message observable gets used by other classes in future - default: - break; - } - return 0; -} - -bool Screen::isOverlayBannerShowing() -{ - return NotificationRenderer::isOverlayBannerShowing(); -} - -} // namespace graphics - -#else -graphics::Screen::Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY) {} -#endif // HAS_SCREEN - -bool shouldWakeOnReceivedMessage() -{ - /* - The goal here is to determine when we do NOT wake up the screen on message received: - - Any ext. notifications are turned on - - If role is not CLIENT / CLIENT_MUTE / CLIENT_HIDDEN / CLIENT_BASE - - If the battery level is very low - */ - if (moduleConfig.external_notification.enabled) { - return false; - } - if (!IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_CLIENT, - meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE, meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN, - meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) { - return false; - } - if (powerStatus && powerStatus->getBatteryChargePercent() < 10) { - return false; - } - return true; -} diff --git a/Screen.h b/Screen.h deleted file mode 100644 index 4bb80897093..00000000000 --- a/Screen.h +++ /dev/null @@ -1,743 +0,0 @@ -#pragma once - -#include "configuration.h" - -#include "detect/ScanI2C.h" -#include "mesh/generated/meshtastic/config.pb.h" -#include -#include -#include -#include - -#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) -namespace graphics -{ -enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, text_input }; - -struct BannerOverlayOptions { - const char *message; - uint32_t durationMs = 30000; - const char **optionsArrayPtr = nullptr; - const int *optionsEnumPtr = nullptr; - uint8_t optionsCount = 0; - std::function bannerCallback = nullptr; - int8_t InitialSelected = 0; - notificationTypeEnum notificationType = notificationTypeEnum::text_banner; -}; -} // namespace graphics - -bool shouldWakeOnReceivedMessage(); - -#if !HAS_SCREEN -#include "power.h" -namespace graphics -{ -// Noop class for boards without screen. -class Screen -{ - public: - enum FrameFocus : uint8_t { - FOCUS_DEFAULT, // No specific frame - FOCUS_PRESERVE, // Return to the previous frame - FOCUS_FAULT, - FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus - FOCUS_CLOCK, - FOCUS_SYSTEM, - }; - - explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY); - void onPress() {} - void setup() {} - void setOn(bool) {} - void doDeepSleep() {} - void forceDisplay(bool forceUiUpdate = false) {} - void startFirmwareUpdateScreen() {} - void increaseBrightness() {} - void decreaseBrightness() {} - void startAlert(const char *) {} - void showSimpleBanner(const char *message, uint32_t durationMs = 0) {} - void showOverlayBanner(BannerOverlayOptions) {} - void setFrames(FrameFocus focus) {} - void endAlert() {} -}; -} // namespace graphics -#else -#include - -#include - -#include "../configuration.h" -#include "gps/GeoCoord.h" -#include "graphics/ScreenFonts.h" - -#ifdef USE_ST7567 -#include -#elif defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64) -#include -#elif defined(USE_SSD1306) -#include -#elif defined(USE_ST7789) -#include -#elif defined(USE_SPISSD1306) -#include -#elif defined(USE_ST7796) -#include -#else -// the SH1106/SSD1306 variant is auto-detected -#include -#endif - -#include "EInkDisplay2.h" -#include "EInkDynamicDisplay.h" -#include "PointStruct.h" -#include "TFTDisplay.h" -#include "TypedQueue.h" -#include "commands.h" -#include "concurrency/LockGuard.h" -#include "concurrency/OSThread.h" -#include "graphics/draw/MenuHandler.h" -#include "input/InputBroker.h" -#include "mesh/MeshModule.h" -#include "modules/AdminModule.h" -#include "power.h" -#include -#include - -// 0 to 255, though particular variants might define different defaults -#ifndef BRIGHTNESS_DEFAULT -#define BRIGHTNESS_DEFAULT 150 -#endif - -// Meters to feet conversion -#ifndef METERS_TO_FEET -#define METERS_TO_FEET 3.28 -#endif - -// Feet to miles conversion -#ifndef MILES_TO_FEET -#define MILES_TO_FEET 5280 -#endif - -// Intuitive colors. E-Ink display is inverted from OLED(?) -#define EINK_BLACK OLEDDISPLAY_COLOR::WHITE -#define EINK_WHITE OLEDDISPLAY_COLOR::BLACK - -// Base segment dimensions for T-Watch segmented display -#define SEGMENT_WIDTH 16 -#define SEGMENT_HEIGHT 4 - -/// Convert an integer GPS coords to a floating point -#define DegD(i) (i * 1e-7) -extern bool hasUnreadMessage; -namespace -{ -/// A basic 2D point class for drawing -class Point -{ - public: - float x, y; - - Point(float _x, float _y) : x(_x), y(_y) {} - - /// Apply a rotation around zero (standard rotation matrix math) - void rotate(float radian) - { - float cos = cosf(radian), sin = sinf(radian); - float rx = x * cos + y * sin, ry = -x * sin + y * cos; - - x = rx; - y = ry; - } - - void translate(int16_t dx, int dy) - { - x += dx; - y += dy; - } - - void scale(float f) - { - // We use -f here to counter the flip that happens - // on the y axis when drawing and rotating on screen - x *= f; - y *= -f; - } -}; - -} // namespace - -namespace graphics -{ - -enum class FrameDirection { NEXT, PREVIOUS }; - -// Forward declarations -class Screen; - -/// Handles gathering and displaying debug information. -class DebugInfo -{ - public: - DebugInfo(const DebugInfo &) = delete; - DebugInfo &operator=(const DebugInfo &) = delete; - - private: - friend Screen; - - DebugInfo() {} - - /// Renders the debug screen. - void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - - /// Protects all of internal state. - concurrency::Lock lock; -}; - -/** - * @brief This class deals with showing things on the screen of the device. - * - * @details Other than setup(), this class is thread-safe as long as drawFrame is not called - * multiple times simultaneously. All state-changing calls are queued and executed - * when the main loop calls us. - */ -class Screen : public concurrency::OSThread -{ - CallbackObserver powerStatusObserver = - CallbackObserver(this, &Screen::handleStatusUpdate); - CallbackObserver gpsStatusObserver = - CallbackObserver(this, &Screen::handleStatusUpdate); - CallbackObserver nodeStatusObserver = - CallbackObserver(this, &Screen::handleStatusUpdate); - CallbackObserver uiFrameEventObserver = - CallbackObserver(this, &Screen::handleUIFrameEvent); // Sent by Mesh Modules - CallbackObserver inputObserver = - CallbackObserver(this, &Screen::handleInputEvent); - CallbackObserver adminMessageObserver = - CallbackObserver(this, &Screen::handleAdminMessage); - - public: - OLEDDisplay *getDisplayDevice() { return dispdev; } - explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY); - - // Screen dimension accessors - inline int getHeight() const { return displayHeight; } - inline int getWidth() const { return displayWidth; } - size_t frameCount = 0; // Total number of active frames - ~Screen(); - - // Which frame we want to be displayed, after we regen the frameset by calling setFrames - enum FrameFocus : uint8_t { - FOCUS_DEFAULT, // No specific frame - FOCUS_PRESERVE, // Return to the previous frame - FOCUS_FAULT, - FOCUS_MODULE, // Note: target module should call requestFocus(), otherwise no info about which module to focus - FOCUS_CLOCK, - FOCUS_SYSTEM, - }; - - // Regenerate the normal set of frames, focusing a specific frame if requested - // Call when a frame should be added / removed, or custom frames should be cleared - void setFrames(FrameFocus focus = FOCUS_DEFAULT); - - std::vector indicatorIcons; // Per-frame custom icon pointers - Screen(const Screen &) = delete; - Screen &operator=(const Screen &) = delete; - - ScanI2C::DeviceAddress address_found; - meshtastic_Config_DisplayConfig_OledType model; - OLEDDISPLAY_GEOMETRY geometry; - - bool isOverlayBannerShowing(); - - bool isScreenOn() { return screenOn; } - - // Stores the last 4 of our hardware ID, to make finding the device for pairing easier - // FIXME: Needs refactoring and getMacAddr needs to be moved to a utility class - char ourId[5]; - - /// Initializes the UI, turns on the display, starts showing boot screen. - // - // Not thread safe - must be called before any other methods are called. - void setup(); - - /// Turns the screen on/off. Optionally, pass a custom screensaver frame for E-Ink - void setOn(bool on, FrameCallback einkScreensaver = NULL); - /** - * Prepare the display for the unit going to the lowest power mode possible. Most screens will just - * poweroff, but eink screens will show a "I'm sleeping" graphic, possibly with a QR code - */ - void doDeepSleep(); - - void blink(); - - // Draw north - float estimatedHeading(double lat, double lon); - - /// Handle button press, trackball or swipe action) - void onPress() { enqueueCmd(ScreenCmd{.cmd = Cmd::ON_PRESS}); } - void showPrevFrame() { enqueueCmd(ScreenCmd{.cmd = Cmd::SHOW_PREV_FRAME}); } - void showNextFrame() { enqueueCmd(ScreenCmd{.cmd = Cmd::SHOW_NEXT_FRAME}); } - void showFrame(FrameDirection direction); - - // generic alert start - void startAlert(FrameCallback _alertFrame) - { - alertFrame = _alertFrame; - ScreenCmd cmd; - cmd.cmd = Cmd::START_ALERT_FRAME; - enqueueCmd(cmd); - } - - void startAlert(const char *_alertMessage) - { - startAlert([_alertMessage](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { - uint16_t x_offset = display->width() / 2; - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_MEDIUM); - display->drawString(x_offset + x, 26 + y, _alertMessage); - }); - } - - void endAlert() - { - ScreenCmd cmd; - cmd.cmd = Cmd::STOP_ALERT_FRAME; - enqueueCmd(cmd); - } - - void showSimpleBanner(const char *message, uint32_t durationMs = 0); - void showOverlayBanner(BannerOverlayOptions); - - void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback); - void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function bannerCallback); - void showTextInput(const char *header, const char *initialText, uint32_t durationMs, - std::function textCallback); - - void requestMenu(graphics::menuHandler::screenMenus menuToShow) - { - graphics::menuHandler::menuQueue = menuToShow; - runNow(); - } - - void startFirmwareUpdateScreen() - { - ScreenCmd cmd; - cmd.cmd = Cmd::START_FIRMWARE_UPDATE_SCREEN; - enqueueCmd(cmd); - } - - // Function to allow the AccelerometerThread to set the heading if a sensor provides it - // Mutex needed? - void setHeading(long _heading) - { - hasCompass = true; - compassHeading = fmod(_heading, 360); - } - - bool hasHeading() { return hasCompass; } - - long getHeading() { return compassHeading; } - - void setEndCalibration(uint32_t _endCalibrationAt) { endCalibrationAt = _endCalibrationAt; } - uint32_t getEndCalibration() { return endCalibrationAt; } - - // functions for display brightness - void increaseBrightness(); - void decreaseBrightness(); - - /// Stops showing the boot screen. - void stopBootScreen() { enqueueCmd(ScreenCmd{.cmd = Cmd::STOP_BOOT_SCREEN}); } - - void runNow() - { - setFastFramerate(); - enqueueCmd(ScreenCmd{.cmd = Cmd::NOOP}); - } - - /// Overrides the default utf8 character conversion, to replace empty space with question marks - static char customFontTableLookup(const uint8_t ch) - { - // UTF-8 to font table index converter - // Code from http://playground.arduino.cc/Main/Utf8ascii - static uint8_t LASTCHAR; - static bool SKIPREST; // Only display a single unconvertable-character symbol per sequence of unconvertable characters - - if (ch < 128) { // Standard ASCII-set 0..0x7F handling - LASTCHAR = 0; - SKIPREST = false; - return ch; - } - - uint8_t last = LASTCHAR; // get last char - LASTCHAR = ch; - - switch (last) { - case 0xC2: { - SKIPREST = false; - return (uint8_t)ch; - } - - case 0xC3: { - SKIPREST = false; - return (uint8_t)(ch | 0xC0); - } - } - - // We want to strip out prefix chars for two-byte char formats - if (ch == 0xC2 || ch == 0xC3) - return (uint8_t)0; - -#if defined(OLED_PL) - - switch (last) { - case 0xC3: { - - if (ch == 147) - return (uint8_t)(ch); // Ó - else if (ch == 179) - return (uint8_t)(148); // ó - else - return (uint8_t)(ch | 0xC0); - break; - } - - case 0xC4: { - SKIPREST = false; - return (uint8_t)(ch); - } - - case 0xC5: { - SKIPREST = false; - if (ch == 132) - return (uint8_t)(136); // ń - else if (ch == 186) - return (uint8_t)(137); // ź - else - return (uint8_t)(ch); - break; - } - } - - // We want to strip out prefix chars for two-byte char formats - if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5) - return (uint8_t)0; - -#endif - -#if defined(OLED_UA) || defined(OLED_RU) - - switch (last) { - case 0xC3: { - SKIPREST = false; - return (uint8_t)(ch | 0xC0); - } - // map UTF-8 cyrillic chars to it Windows-1251 (CP-1251) ASCII codes - // note: in this case we must use compatible font - provided ArialMT_Plain_10/16/24 by 'ThingPulse/esp8266-oled-ssd1306' - // library have empty chars for non-latin ASCII symbols - case 0xD0: { - SKIPREST = false; - if (ch == 132) - return (uint8_t)(170); // Є - if (ch == 134) - return (uint8_t)(178); // І - if (ch == 135) - return (uint8_t)(175); // Ї - if (ch == 129) - return (uint8_t)(168); // Ё - if (ch > 143 && ch < 192) - return (uint8_t)(ch + 48); - break; - } - case 0xD1: { - SKIPREST = false; - if (ch == 148) - return (uint8_t)(186); // є - if (ch == 150) - return (uint8_t)(179); // і - if (ch == 151) - return (uint8_t)(191); // ї - if (ch == 145) - return (uint8_t)(184); // ё - if (ch > 127 && ch < 144) - return (uint8_t)(ch + 112); - break; - } - case 0xD2: { - SKIPREST = false; - if (ch == 144) - return (uint8_t)(165); // Ґ - if (ch == 145) - return (uint8_t)(180); // ґ - break; - } - } - - // We want to strip out prefix chars for two-byte char formats - if (ch == 0xC2 || ch == 0xC3 || ch == 0x82 || ch == 0xD0 || ch == 0xD1) - return (uint8_t)0; - -#endif - -#if defined(OLED_CS) - - switch (last) { - case 0xC2: { - SKIPREST = false; - return (uint8_t)ch; - } - - case 0xC3: { - SKIPREST = false; - return (uint8_t)(ch | 0xC0); - } - - case 0xC4: { - SKIPREST = false; - if (ch == 140) - return (uint8_t)(129); // Č - if (ch == 141) - return (uint8_t)(138); // č - if (ch == 142) - return (uint8_t)(130); // Ď - if (ch == 143) - return (uint8_t)(139); // ď - if (ch == 154) - return (uint8_t)(131); // Ě - if (ch == 155) - return (uint8_t)(140); // ě - // Slovak specific glyphs - if (ch == 185) - return (uint8_t)(147); // Ĺ - if (ch == 186) - return (uint8_t)(148); // ĺ - if (ch == 189) - return (uint8_t)(149); // Ľ - if (ch == 190) - return (uint8_t)(150); // ľ - break; - } - - case 0xC5: { - SKIPREST = false; - if (ch == 135) - return (uint8_t)(132); // Ň - if (ch == 136) - return (uint8_t)(141); // ň - if (ch == 152) - return (uint8_t)(133); // Ř - if (ch == 153) - return (uint8_t)(142); // ř - if (ch == 160) - return (uint8_t)(134); // Š - if (ch == 161) - return (uint8_t)(143); // š - if (ch == 164) - return (uint8_t)(135); // Ť - if (ch == 165) - return (uint8_t)(144); // ť - if (ch == 174) - return (uint8_t)(136); // Ů - if (ch == 175) - return (uint8_t)(145); // ů - if (ch == 189) - return (uint8_t)(137); // Ž - if (ch == 190) - return (uint8_t)(146); // ž - // Slovak specific glyphs - if (ch == 148) - return (uint8_t)(151); // Ŕ - if (ch == 149) - return (uint8_t)(152); // ŕ - break; - } - } - - // We want to strip out prefix chars for two-byte char formats - if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5) - return (uint8_t)0; - -#endif - - // If we already returned an unconvertable-character symbol for this unconvertable-character sequence, return NULs for the - // rest of it - if (SKIPREST) - return (uint8_t)0; - SKIPREST = true; - - return (uint8_t)191; // otherwise: return ¿ if character can't be converted (note that the font map we're using doesn't - // stick to standard EASCII codes) - } - - /// Returns a handle to the DebugInfo screen. - // - // Use this handle to set things like battery status, user count, GPS status, etc. - DebugInfo *debug_info() { return &debugInfo; } - - // Handle observer events - int handleStatusUpdate(const meshtastic::Status *arg); - int handleTextMessage(const meshtastic_MeshPacket *packet); - int handleUIFrameEvent(const UIFrameEvent *arg); - int handleInputEvent(const InputEvent *arg); - int handleAdminMessage(AdminModule_ObserverData *arg); - - /// Used to force (super slow) eink displays to draw critical frames - void forceDisplay(bool forceUiUpdate = false); - - /// Draws our SSL cert screen during boot (called from WebServer) - void setSSLFrames(); - - // Menu-driven Show / Hide Toggle - void toggleFrameVisibility(const std::string &frameName); - bool isFrameHidden(const std::string &frameName) const; - -#ifdef USE_EINK - /// Draw an image to remain on E-Ink display after screen off - void setScreensaverFrames(FrameCallback einkScreensaver = NULL); -#endif - - protected: - /// Updates the UI. - // - // Called periodically from the main loop. - int32_t runOnce() final; - - bool isAUTOOled = false; - - // Screen dimensions (for convenience) - // Defined during Screen::setup - uint16_t displayWidth = 0; - uint16_t displayHeight = 0; - - private: - FrameCallback alertFrames[1]; - struct ScreenCmd { - Cmd cmd; - union { - uint32_t bluetooth_pin; - char *print_text; - }; - }; - - /// Enques given command item to be processed by main loop(). - bool enqueueCmd(const ScreenCmd &cmd) - { - if (!useDisplay) - return false; // not enqueued if our display is not in use - else { - bool success = cmdQueue.enqueue(cmd, 0); - enabled = true; // handle ASAP (we are the registered reader for cmdQueue, but might have been disabled) - return success; - } - } - - // Implementations of various commands, called from doTask(). - void handleSetOn(bool on, FrameCallback einkScreensaver = NULL); - void handleOnPress(); - void handleStartFirmwareUpdateScreen(); - - // Info collected by setFrames method. - // Index location of specific frames. - // - Used to apply the FrameFocus parameter of setFrames - // - Used to dismiss the currently shown frame (txt; waypoint) by CardKB combo - struct FramesetInfo { - struct FramePositions { - uint8_t fault = 255; - uint8_t waypoint = 255; - uint8_t focusedModule = 255; - uint8_t log = 255; - uint8_t settings = 255; - uint8_t wifi = 255; - uint8_t deviceFocused = 255; - uint8_t system = 255; - uint8_t gps = 255; - uint8_t home = 255; - uint8_t textMessage = 255; - uint8_t nodelist_nodes = 255; - uint8_t nodelist_location = 255; - uint8_t nodelist_lastheard = 255; - uint8_t nodelist_hopsignal = 255; - uint8_t nodelist_distance = 255; - uint8_t nodelist_bearings = 255; - uint8_t clock = 255; - uint8_t chirpy = 255; - uint8_t firstFavorite = 255; - uint8_t lastFavorite = 255; - uint8_t lora = 255; - } positions; - - uint8_t frameCount = 0; - } framesetInfo; - - struct hiddenFrames { - bool textMessage = false; - bool waypoint = false; - bool wifi = false; - bool system = false; - bool home = false; - bool clock = false; -#ifndef USE_EINK - bool nodelist_nodes = false; - bool nodelist_location = false; -#endif -#ifdef USE_EINK - bool nodelist_lastheard = false; - bool nodelist_hopsignal = false; - bool nodelist_distance = false; -#endif -#if HAS_GPS -#ifdef USE_EINK - bool nodelist_bearings = false; -#endif - bool gps = false; -#endif - bool lora = false; - bool show_favorites = false; - bool chirpy = true; - } hiddenFrames; - - /// Try to start drawing ASAP - void setFastFramerate(); - - // Sets frame up for immediate drawing - void setFrameImmediateDraw(FrameCallback *drawFrames); - - /// callback for current alert frame - FrameCallback alertFrame; - - /// Queue of commands to execute in doTask. - TypedQueue cmdQueue; - /// Whether we are using a display - bool useDisplay = false; - /// Whether the display is currently powered - bool screenOn = false; - // Whether we are showing the regular screen (as opposed to booth screen or - // Bluetooth PIN screen) - bool showingNormalScreen = false; - - // Implementation to Adjust Brightness - uint8_t brightness = BRIGHTNESS_DEFAULT; // H = 254, MH = 192, ML = 130 L = 103 - - bool hasCompass = false; - float compassHeading; - uint32_t endCalibrationAt; - - /// Holds state for debug information - DebugInfo debugInfo; - - /// Display device - OLEDDisplay *dispdev; - - /// UI helper for rendering to frames and switching between them - OLEDDisplayUi *ui; -}; - -} // namespace graphics - -// Extern declarations for function symbols used in UIRenderer -extern std::vector functionSymbol; -extern std::string functionSymbolString; -extern graphics::Screen *screen; - -#endif \ No newline at end of file diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 9a6b97c017f..149dd9eacf8 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1046,11 +1046,16 @@ void Screen::setFrames(FrameFocus focus) indicatorIcons.push_back(icon_mail); #ifndef USE_EINK - if (!hiddenFrames.nodelist) { - fsi.positions.nodelist = numframes; - normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicNodeListScreen; + if (!hiddenFrames.nodelist_nodes) { + fsi.positions.nodelist_nodes = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Nodes; indicatorIcons.push_back(icon_nodes); } + if (!hiddenFrames.nodelist_location) { + fsi.positions.nodelist_location = numframes; + normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Location; + indicatorIcons.push_back(icon_list); + } #endif // Show detailed node views only on E-Ink builds @@ -1072,11 +1077,13 @@ void Screen::setFrames(FrameFocus focus) } #endif #if HAS_GPS +#ifdef USE_EINK if (!hiddenFrames.nodelist_bearings) { fsi.positions.nodelist_bearings = numframes; normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; indicatorIcons.push_back(icon_list); } +#endif if (!hiddenFrames.gps) { fsi.positions.gps = numframes; normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen; @@ -1238,8 +1245,11 @@ void Screen::setFrameImmediateDraw(FrameCallback *drawFrames) void Screen::toggleFrameVisibility(const std::string &frameName) { #ifndef USE_EINK - if (frameName == "nodelist") { - hiddenFrames.nodelist = !hiddenFrames.nodelist; + if (frameName == "nodelist_nodes") { + hiddenFrames.nodelist_nodes = !hiddenFrames.nodelist_nodes; + } + if (frameName == "nodelist_location") { + hiddenFrames.nodelist_location = !hiddenFrames.nodelist_location; } #endif #ifdef USE_EINK @@ -1254,9 +1264,11 @@ void Screen::toggleFrameVisibility(const std::string &frameName) } #endif #if HAS_GPS +#ifdef USE_EINK if (frameName == "nodelist_bearings") { hiddenFrames.nodelist_bearings = !hiddenFrames.nodelist_bearings; } +#endif if (frameName == "gps") { hiddenFrames.gps = !hiddenFrames.gps; } @@ -1278,8 +1290,10 @@ void Screen::toggleFrameVisibility(const std::string &frameName) bool Screen::isFrameHidden(const std::string &frameName) const { #ifndef USE_EINK - if (frameName == "nodelist") - return hiddenFrames.nodelist; + if (frameName == "nodelist_nodes") + return hiddenFrames.nodelist_nodes; + if (frameName == "nodelist_location") + return hiddenFrames.nodelist_location; #endif #ifdef USE_EINK if (frameName == "nodelist_lastheard") @@ -1290,8 +1304,10 @@ bool Screen::isFrameHidden(const std::string &frameName) const return hiddenFrames.nodelist_distance; #endif #if HAS_GPS +#ifdef USE_EINK if (frameName == "nodelist_bearings") return hiddenFrames.nodelist_bearings; +#endif if (frameName == "gps") return hiddenFrames.gps; #endif @@ -1716,7 +1732,8 @@ int Screen::handleInputEvent(const InputEvent *event) this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { menuHandler::favoriteBaseMenu(); - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist || + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_nodes || + this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_location || this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_lastheard || this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_hopsignal || this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_distance || diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index d6e71d356c2..4bb80897093 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -654,7 +654,8 @@ class Screen : public concurrency::OSThread uint8_t gps = 255; uint8_t home = 255; uint8_t textMessage = 255; - uint8_t nodelist = 255; + uint8_t nodelist_nodes = 255; + uint8_t nodelist_location = 255; uint8_t nodelist_lastheard = 255; uint8_t nodelist_hopsignal = 255; uint8_t nodelist_distance = 255; @@ -677,7 +678,8 @@ class Screen : public concurrency::OSThread bool home = false; bool clock = false; #ifndef USE_EINK - bool nodelist = false; + bool nodelist_nodes = false; + bool nodelist_location = false; #endif #ifdef USE_EINK bool nodelist_lastheard = false; @@ -685,7 +687,9 @@ class Screen : public concurrency::OSThread bool nodelist_distance = false; #endif #if HAS_GPS +#ifdef USE_EINK bool nodelist_bearings = false; +#endif bool gps = false; #endif bool lora = false; diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 99c4d291585..f07d29a667c 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -1111,20 +1111,33 @@ void menuHandler::positionBaseMenu() void menuHandler::nodeListMenu() { - enum optionsNumbers { Back, Favorite, TraceRoute, Verify, Reset, enumEnd }; -#if defined(M5STACK_UNITC6L) - static const char *optionsArray[] = {"Back", "Add Favorite", "Reset Node"}; -#else - static const char *optionsArray[] = {"Back", "Add Favorite", "Trace Route", "Key Verification", "Reset NodeDB"}; + enum optionsNumbers { Back, Favorite, TraceRoute, Verify, Reset, NodeNameLength, enumEnd }; + static const char *optionsArray[enumEnd] = {"Back"}; + static int optionsEnumArray[enumEnd] = {Back}; + int options = 1; + + optionsArray[options] = "Add Favorite"; + optionsEnumArray[options++] = Favorite; + optionsArray[options] = "Trace Route"; + optionsEnumArray[options++] = TraceRoute; + +#if !defined(M5STACK_UNITC6L) + optionsArray[options] = "Key Verification"; + optionsEnumArray[options++] = Verify; +#endif + +#if defined(T_DECK) || defined(T_LORA_PAGER) || defined(HACKADAY_COMMUNICATOR) + optionsArray[options] = "Show Long/Short Name"; + optionsEnumArray[options++] = NodeNameLength; #endif + optionsArray[options] = "Reset NodeDB"; + optionsEnumArray[options++] = Reset; + BannerOverlayOptions bannerOptions; bannerOptions.message = "Node Action"; bannerOptions.optionsArrayPtr = optionsArray; -#if defined(M5STACK_UNITC6L) - bannerOptions.optionsCount = 3; -#else - bannerOptions.optionsCount = 5; -#endif + bannerOptions.optionsCount = options; + bannerOptions.optionsEnumPtr = optionsEnumArray; bannerOptions.bannerCallback = [](int selected) -> void { if (selected == Favorite) { menuQueue = add_favorite; @@ -1138,6 +1151,9 @@ void menuHandler::nodeListMenu() } else if (selected == TraceRoute) { menuQueue = trace_route_menu; screen->runNow(); + } else if (selected == NodeNameLength) { + menuHandler::menuQueue = menuHandler::node_name_length_menu; + screen->runNow(); } }; screen->showOverlayBanner(bannerOptions); @@ -1752,16 +1768,11 @@ void menuHandler::screenOptionsMenu() hasSupportBrightness = false; #endif - enum optionsNumbers { Back, NodeNameLength, Brightness, ScreenColor, FrameToggles, DisplayUnits }; + enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits }; static const char *optionsArray[5] = {"Back"}; static int optionsEnumArray[5] = {Back}; int options = 1; -#if defined(T_DECK) || defined(T_LORA_PAGER) || defined(HACKADAY_COMMUNICATOR) - optionsArray[options] = "Show Long/Short Name"; - optionsEnumArray[options++] = NodeNameLength; -#endif - // Only show brightness for B&W displays if (hasSupportBrightness) { optionsArray[options] = "Brightness"; @@ -1793,9 +1804,6 @@ void menuHandler::screenOptionsMenu() } else if (selected == ScreenColor) { menuHandler::menuQueue = menuHandler::tftcolormenupicker; screen->runNow(); - } else if (selected == NodeNameLength) { - menuHandler::menuQueue = menuHandler::node_name_length_menu; - screen->runNow(); } else if (selected == FrameToggles) { menuHandler::menuQueue = menuHandler::FrameToggles; screen->runNow(); @@ -1891,7 +1899,8 @@ void menuHandler::FrameToggles_menu() { enum optionsNumbers { Finish, - nodelist, + nodelist_nodes, + nodelist_location, nodelist_lastheard, nodelist_hopsignal, nodelist_distance, @@ -1912,20 +1921,25 @@ void menuHandler::FrameToggles_menu() static int lastSelectedIndex = 0; #ifndef USE_EINK - optionsArray[options] = screen->isFrameHidden("nodelist") ? "Show Node List" : "Hide Node List"; - optionsEnumArray[options++] = nodelist; -#endif -#ifdef USE_EINK + optionsArray[options] = screen->isFrameHidden("nodelist_nodes") ? "Show Node List" : "Hide Node List"; + optionsEnumArray[options++] = nodelist_nodes; +#else optionsArray[options] = screen->isFrameHidden("nodelist_lastheard") ? "Show NL - Last Heard" : "Hide NL - Last Heard"; optionsEnumArray[options++] = nodelist_lastheard; optionsArray[options] = screen->isFrameHidden("nodelist_hopsignal") ? "Show NL - Hops/Signal" : "Hide NL - Hops/Signal"; optionsEnumArray[options++] = nodelist_hopsignal; - optionsArray[options] = screen->isFrameHidden("nodelist_distance") ? "Show NL - Distance" : "Hide NL - Distance"; - optionsEnumArray[options++] = nodelist_distance; #endif + #if HAS_GPS - optionsArray[options] = screen->isFrameHidden("nodelist_bearings") ? "Show Bearings" : "Hide Bearings"; +#ifndef USE_EINK + optionsArray[options] = screen->isFrameHidden("nodelist_location") ? "Show Node Location List" : "Hide Node Location List"; + optionsEnumArray[options++] = nodelist_location; +#else + optionsArray[options] = screen->isFrameHidden("nodelist_distance") ? "Show NL - Distance" : "Hide NL - Distance"; + optionsEnumArray[options++] = nodelist_distance; + optionsArray[options] = screen->isFrameHidden("nodelist_bearings") ? "Show NL - Bearings" : "Hide NL - Bearings"; optionsEnumArray[options++] = nodelist_bearings; +#endif optionsArray[options] = screen->isFrameHidden("gps") ? "Show Position" : "Hide Position"; optionsEnumArray[options++] = gps; @@ -1964,8 +1978,12 @@ void menuHandler::FrameToggles_menu() if (selected == Finish) { screen->setFrames(Screen::FOCUS_DEFAULT); - } else if (selected == nodelist) { - screen->toggleFrameVisibility("nodelist"); + } else if (selected == nodelist_nodes) { + screen->toggleFrameVisibility("nodelist_nodes"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); + } else if (selected == nodelist_location) { + screen->toggleFrameVisibility("nodelist_location"); menuHandler::menuQueue = menuHandler::FrameToggles; screen->runNow(); } else if (selected == nodelist_lastheard) { @@ -2184,4 +2202,4 @@ void menuHandler::saveUIConfig() } // namespace graphics -#endif +#endif \ No newline at end of file diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index b073f17b3bd..a1020268e07 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -46,7 +46,8 @@ void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t * } // Static variables for dynamic cycling -static NodeListMode currentMode = MODE_LAST_HEARD; +static ListMode_Node currentMode_Nodes = MODE_LAST_HEARD; +static ListMode_Location currentMode_Location = MODE_DISTANCE; static int scrollIndex = 0; // ============================= @@ -55,7 +56,7 @@ static int scrollIndex = 0; const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node) { - static char nodeName[16]; // single static buffer we return + static char nodeName[25]; // single static buffer we return nodeName[0] = '\0'; auto writeFallbackId = [&] { @@ -109,9 +110,9 @@ const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node) return nodeName; } -const char *getCurrentModeTitle(int screenWidth) +const char *getCurrentModeTitle_Nodes(int screenWidth) { - switch (currentMode) { + switch (currentMode_Nodes) { case MODE_LAST_HEARD: return "Last Heard"; case MODE_HOP_SIGNAL: @@ -120,8 +121,18 @@ const char *getCurrentModeTitle(int screenWidth) #else return (isHighResolution) ? "Hops/Signal" : "Hops/Sig"; #endif + default: + return "Nodes"; + } +} + +const char *getCurrentModeTitle_Location(int screenWidth) +{ + switch (currentMode_Location) { case MODE_DISTANCE: return "Distance"; + case MODE_BEARING: + return "Bearings"; default: return "Nodes"; } @@ -140,10 +151,8 @@ int calculateMaxScroll(int totalEntries, int visibleRows) void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd) { - int columnWidth = display->getWidth() / 2; - int separatorX = x + columnWidth - 2; for (int y = yStart; y <= yEnd; y += 2) { - display->setPixel(separatorX, y); + display->setPixel(x, y); } } @@ -332,18 +341,15 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 } } -void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) +void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) { - switch (currentMode) { + switch (currentMode_Nodes) { case MODE_LAST_HEARD: drawEntryLastHeard(display, node, x, y, columnWidth); break; case MODE_HOP_SIGNAL: drawEntryHopSignal(display, node, x, y, columnWidth); break; - case MODE_DISTANCE: - drawNodeDistance(display, node, x, y, columnWidth); - break; default: break; } @@ -436,9 +442,15 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t locationScreen = true; #if defined(M5STACK_UNITC6L) int columnWidth = display->getWidth(); +#elif defined(T_LORA_PAGER) + int columnWidth = display->getWidth() / 3; + if (config.display.use_long_node_name) { + columnWidth = display->getWidth() / 2; + } #else int columnWidth = display->getWidth() / 2; #endif + display->clear(); // Draw the battery/time header @@ -453,6 +465,11 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t int visibleNodeRows = totalRowsAvailable; #if defined(M5STACK_UNITC6L) int totalColumns = 1; +#elif defined(T_LORA_PAGER) + int totalColumns = 3; + if (config.display.use_long_node_name) { + totalColumns = 2; + } #else int totalColumns = 2; #endif @@ -502,7 +519,9 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t // Draw column separator if (shownCount > 0) { const int firstNodeY = y + 3; - drawColumnSeparator(display, x, firstNodeY, lastNodeY); + for (int horizontal_offset = 1; horizontal_offset < totalColumns; horizontal_offset++) { + drawColumnSeparator(display, columnWidth * horizontal_offset, firstNodeY, lastNodeY); + } } #endif @@ -516,10 +535,47 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t // ============================= #ifndef USE_EINK -void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +// Node list for Last Heard and Hop Signal views +void drawDynamicListScreen_Nodes(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + // Static variables to track mode and duration + static ListMode_Node lastRenderedMode = MODE_COUNT_NODE; + static unsigned long modeStartTime = 0; + + unsigned long now = millis(); + +#if defined(M5STACK_UNITC6L) + display->clear(); + if (now - lastSwitchTime >= 3000) { + display->display(); + lastSwitchTime = now; + } +#endif + // On very first call (on boot or state enter) + if (lastRenderedMode == MODE_COUNT_NODE) { + currentMode_Nodes = MODE_LAST_HEARD; + modeStartTime = now; + } + + // Time to switch to next mode? + if (now - modeStartTime >= getModeCycleIntervalMs()) { + currentMode_Nodes = static_cast((currentMode_Nodes + 1) % MODE_COUNT_NODE); + modeStartTime = now; + } + + // Render screen based on currentMode + const char *title = getCurrentModeTitle_Nodes(display->getWidth()); + drawNodeListScreen(display, state, x, y, title, drawEntryDynamic_Nodes); + + // Track the last mode to avoid reinitializing modeStartTime + lastRenderedMode = currentMode_Nodes; +} + +// Node list for Distance and Bearings views +void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { // Static variables to track mode and duration - static NodeListMode lastRenderedMode = MODE_COUNT; + static ListMode_Location lastRenderedMode = MODE_COUNT_LOCATION; static unsigned long modeStartTime = 0; unsigned long now = millis(); @@ -532,23 +588,29 @@ void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, } #endif // On very first call (on boot or state enter) - if (lastRenderedMode == MODE_COUNT) { - currentMode = MODE_LAST_HEARD; + if (lastRenderedMode == MODE_COUNT_LOCATION) { + currentMode_Location = MODE_DISTANCE; modeStartTime = now; } // Time to switch to next mode? if (now - modeStartTime >= getModeCycleIntervalMs()) { - currentMode = static_cast((currentMode + 1) % MODE_COUNT); + currentMode_Location = static_cast((currentMode_Location + 1) % MODE_COUNT_LOCATION); modeStartTime = now; } // Render screen based on currentMode - const char *title = getCurrentModeTitle(display->getWidth()); - drawNodeListScreen(display, state, x, y, title, drawEntryDynamic); + const char *title = getCurrentModeTitle_Location(display->getWidth()); + + // Render screen based on currentMode_Location + if (currentMode_Location == MODE_DISTANCE) { + drawNodeListScreen(display, state, x, y, title, drawNodeDistance); + } else if (currentMode_Location == MODE_BEARING) { + drawNodeListWithCompasses(display, state, x, y); + } // Track the last mode to avoid reinitializing modeStartTime - lastRenderedMode = currentMode; + lastRenderedMode = currentMode_Location; } #endif @@ -569,14 +631,12 @@ void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_ #endif drawNodeListScreen(display, state, x, y, title, drawEntryHopSignal); } - void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { const char *title = "Distance"; drawNodeListScreen(display, state, x, y, title, drawNodeDistance); } #endif - void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { float heading = 0; diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index ea8df8bd9ce..8b82fb1177c 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -23,8 +23,11 @@ namespace NodeListRenderer typedef void (*EntryRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int); typedef void (*NodeExtrasRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int, float, double, double); -// Node list mode enumeration -enum NodeListMode { MODE_LAST_HEARD = 0, MODE_HOP_SIGNAL = 1, MODE_DISTANCE = 2, MODE_COUNT = 3 }; +// Node list mode enumeration for Last Heard and Hop Signal views +enum ListMode_Node { MODE_LAST_HEARD = 0, MODE_HOP_SIGNAL = 1, MODE_COUNT_NODE = 2 }; + +// Node list mode enumeration for Distance and Bearings views +enum ListMode_Location { MODE_DISTANCE = 0, MODE_BEARING = 1, MODE_COUNT_LOCATION = 2 }; // Main node list screen function void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, @@ -35,7 +38,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); -void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); +void drawEntryDynamic_Nodes(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth); // Extras renderers @@ -46,11 +49,13 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); -void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDynamicListScreen_Nodes(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); // Utility functions -const char *getCurrentModeTitle(int screenWidth); +const char *getCurrentModeTitle_Nodes(int screenWidth); +const char *getCurrentModeTitle_Location(int screenWidth); const char *getSafeNodeName(meshtastic_NodeInfoLite *node); void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);