From 093a37a2b0888934a1941bc3f9c525e80255b5da Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 21 Aug 2025 06:31:27 -0500 Subject: [PATCH 01/11] On screen keyboard (#7705) * Add on-screen keyboard implementation on Trackball device. * Update On-Screen Keyboard to new layout. * The on-screen keyboard dynamically adjusts the key size based on the screen. * Improve input box display on small screens. * Optimize the virtual keyboard layout and cursor movement logic, and adjust the keyboard starting position for small and wide screens. * Optimize the text alignment of numeric keys on ssd1306. --------- Co-authored-by: whywilson --- src/buzz/BuzzerFeedbackThread.cpp | 1 + src/graphics/Screen.cpp | 75 ++- src/graphics/Screen.h | 4 +- src/graphics/VirtualKeyboard.cpp | 738 +++++++++++++++++++++ src/graphics/VirtualKeyboard.h | 80 +++ src/graphics/draw/MenuHandler.cpp | 3 + src/graphics/draw/NotificationRenderer.cpp | 111 +++- src/graphics/draw/NotificationRenderer.h | 6 + src/input/InputBroker.h | 1 + src/input/TrackballInterruptBase.cpp | 76 ++- src/input/TrackballInterruptBase.h | 13 +- src/input/TrackballInterruptImpl1.cpp | 7 +- src/main.cpp | 6 + src/main.h | 1 + src/modules/CannedMessageModule.cpp | 136 +++- 15 files changed, 1227 insertions(+), 31 deletions(-) create mode 100644 src/graphics/VirtualKeyboard.cpp create mode 100644 src/graphics/VirtualKeyboard.h diff --git a/src/buzz/BuzzerFeedbackThread.cpp b/src/buzz/BuzzerFeedbackThread.cpp index ce762c764..838224c69 100644 --- a/src/buzz/BuzzerFeedbackThread.cpp +++ b/src/buzz/BuzzerFeedbackThread.cpp @@ -28,6 +28,7 @@ int BuzzerFeedbackThread::handleInputEvent(const InputEvent *event) case INPUT_BROKER_USER_PRESS: case INPUT_BROKER_ALT_PRESS: case INPUT_BROKER_SELECT: + case INPUT_BROKER_SELECT_LONG: playBeep(); // Confirmation feedback break; diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index fa71e17d8..5e29814cb 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -216,6 +216,44 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t 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); + + if (NotificationRenderer::virtualKeyboard) { + delete NotificationRenderer::virtualKeyboard; + NotificationRenderer::virtualKeyboard = nullptr; + } + + NotificationRenderer::textInputCallback = nullptr; + + NotificationRenderer::virtualKeyboard = new VirtualKeyboard(); + if (header) { + NotificationRenderer::virtualKeyboard->setHeader(header); + } + if (initialText) { + NotificationRenderer::virtualKeyboard->setInputText(initialText); + } + + // Set up callback with safer cleanup mechanism + NotificationRenderer::textInputCallback = textCallback; + NotificationRenderer::virtualKeyboard->setCallback([textCallback](const std::string &text) { textCallback(text); }); + + // 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; @@ -713,13 +751,19 @@ int32_t Screen::runOnce() handleSetOn(false); break; case Cmd::ON_PRESS: - handleOnPress(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + handleOnPress(); + } break; case Cmd::SHOW_PREV_FRAME: - handleShowPrevFrame(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + handleShowPrevFrame(); + } break; case Cmd::SHOW_NEXT_FRAME: - handleShowNextFrame(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + handleShowNextFrame(); + } break; case Cmd::START_ALERT_FRAME: { showingBootScreen = false; // this should avoid the edge case where an alert triggers before the boot screen goes away @@ -741,7 +785,9 @@ int32_t Screen::runOnce() NotificationRenderer::pauseBanner = false; case Cmd::STOP_BOOT_SCREEN: EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame - setFrames(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + setFrames(); + } break; case Cmd::NOOP: break; @@ -777,6 +823,7 @@ int32_t Screen::runOnce() 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 @@ -867,6 +914,11 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) // 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 @@ -1313,6 +1365,11 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) // 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) @@ -1335,6 +1392,16 @@ int Screen::handleInputEvent(const InputEvent *event) 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 diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 265900131..0f100d455 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -12,7 +12,7 @@ #define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) namespace graphics { -enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker }; +enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, text_input }; struct BannerOverlayOptions { const char *message; @@ -313,6 +313,8 @@ class Screen : public concurrency::OSThread 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) { diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp new file mode 100644 index 000000000..84d5551cb --- /dev/null +++ b/src/graphics/VirtualKeyboard.cpp @@ -0,0 +1,738 @@ +#include "VirtualKeyboard.h" +#include "configuration.h" +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "main.h" +#include +#include + +namespace graphics +{ + +VirtualKeyboard::VirtualKeyboard() : cursorRow(0), cursorCol(0), lastActivityTime(millis()) +{ + initializeKeyboard(); + // Set cursor to H(2, 5) + cursorRow = 2; + cursorCol = 5; +} + +VirtualKeyboard::~VirtualKeyboard() {} + +void VirtualKeyboard::initializeKeyboard() +{ + // New 4 row, 11 column keyboard layout: + static const char LAYOUT[KEYBOARD_ROWS][KEYBOARD_COLS] = {{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '\b'}, + {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '\n'}, + {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', ' '}, + {'z', 'x', 'c', 'v', 'b', 'n', 'm', '.', ',', '?', '\x1b'}}; + + // Derive layout dimensions and assert they match the configured keyboard grid + constexpr int LAYOUT_ROWS = (int)(sizeof(LAYOUT) / sizeof(LAYOUT[0])); + constexpr int LAYOUT_COLS = (int)(sizeof(LAYOUT[0]) / sizeof(LAYOUT[0][0])); + static_assert(LAYOUT_ROWS == KEYBOARD_ROWS, "LAYOUT rows must equal KEYBOARD_ROWS"); + static_assert(LAYOUT_COLS == KEYBOARD_COLS, "LAYOUT cols must equal KEYBOARD_COLS"); + + // Initialize all keys to empty first + for (int row = 0; row < LAYOUT_ROWS; row++) { + for (int col = 0; col < LAYOUT_COLS; col++) { + keyboard[row][col] = {0, VK_CHAR, 0, 0, 0, 0}; + } + } + + // Fill keyboard from the 2D layout + for (int row = 0; row < LAYOUT_ROWS; row++) { + for (int col = 0; col < LAYOUT_COLS; col++) { + char ch = LAYOUT[row][col]; + // No empty slots in the simplified layout + + VirtualKeyType type = VK_CHAR; + if (ch == '\b') { + type = VK_BACKSPACE; + } else if (ch == '\n') { + type = VK_ENTER; + } else if (ch == '\x1b') { // ESC + type = VK_ESC; + } else if (ch == ' ') { + type = VK_SPACE; + } + + // Make action keys wider to fit text while keeping the last column aligned + uint8_t width = (type == VK_BACKSPACE || type == VK_ENTER || type == VK_SPACE) ? (KEY_WIDTH * 3) : KEY_WIDTH; + keyboard[row][col] = {ch, type, (uint8_t)(col * KEY_WIDTH), (uint8_t)(row * KEY_HEIGHT), width, KEY_HEIGHT}; + } + } +} + +void VirtualKeyboard::draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY) +{ + // Repeat ticking is driven by NotificationRenderer once per frame + // Base styles + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + // Screen geometry + const int screenW = display->getWidth(); + const int screenH = display->getHeight(); + + // Decide wide-screen mode: if there is comfortable width, allow taller keys and reserve fixed width for last column labels + // Heuristic: if screen width >= 200px (e.g., 240x135), treat as wide + const bool isWide = screenW >= 200; + + // Determine last-column label max width + display->setFont(FONT_SMALL); + const int wENTER = display->getStringWidth("ENTER"); + int lastColLabelW = wENTER; // ENTER is usually the widest + // Smaller padding on very small screens to avoid excessive whitespace + const int lastColPad = (screenW <= 128 ? 2 : 6); + const int reservedLastColW = lastColLabelW + lastColPad; // reserved width for last column keys + + // Always reserve width for the rightmost text column to avoid overlap on small screens + int cellW = 0; + int leftoverW = 0; + { + const int leftCols = KEYBOARD_COLS - 1; // 10 input characters + int usableW = screenW - reservedLastColW; + if (usableW < leftCols) { + // Guard: ensure at least 1px per left cell if labels are extremely wide (unlikely) + usableW = leftCols; + } + cellW = usableW / leftCols; + leftoverW = usableW - cellW * leftCols; // distribute extra pixels over left columns (left to right) + } + + // Dynamic key geometry + int cellH = KEY_HEIGHT; + int keyboardStartY = 0; + if (screenH <= 64) { + const int headerHeight = headerText.empty() ? 0 : (FONT_HEIGHT_SMALL - 2); + const int gapBelowHeader = 0; + const int singleLineBoxHeight = FONT_HEIGHT_SMALL; + const int gapAboveKeyboard = 0; + keyboardStartY = offsetY + headerHeight + gapBelowHeader + singleLineBoxHeight + gapAboveKeyboard; + if (keyboardStartY < 0) + keyboardStartY = 0; + if (keyboardStartY > screenH) + keyboardStartY = screenH; + int keyboardHeight = screenH - keyboardStartY; + cellH = std::max(1, keyboardHeight / KEYBOARD_ROWS); + } else if (isWide) { + // For wide screens (e.g., T114 240x135), prefer square keys: height equals left-column key width. + cellH = std::max((int)KEY_HEIGHT, cellW); + + // Guarantee at least 2 lines of input are visible by reducing cell height minimally if needed. + // Replicate the spacing used in drawInputArea(): headerGap=1, box-to-header gap=1, gap above keyboard=1 + display->setFont(FONT_SMALL); + const int headerHeight = headerText.empty() ? 0 : (FONT_HEIGHT_SMALL + 1); + const int headerToBoxGap = 1; + const int gapAboveKb = 1; + const int minBoxHeightForTwoLines = 2 * FONT_HEIGHT_SMALL + 2; // inner 1px top/bottom + int maxKeyboardHeight = screenH - (offsetY + headerHeight + headerToBoxGap + minBoxHeightForTwoLines + gapAboveKb); + int maxCellHAllowed = maxKeyboardHeight / KEYBOARD_ROWS; + if (maxCellHAllowed < (int)KEY_HEIGHT) + maxCellHAllowed = KEY_HEIGHT; + if (maxCellHAllowed > 0 && cellH > maxCellHAllowed) { + cellH = maxCellHAllowed; + } + // Keyboard placement from bottom for wide screens + int keyboardHeight = KEYBOARD_ROWS * cellH; + keyboardStartY = screenH - keyboardHeight; + if (keyboardStartY < 0) + keyboardStartY = 0; + } else { + // Default (non-wide, non-64px) behavior: use key height heuristic and place at bottom + cellH = KEY_HEIGHT; + int keyboardHeight = KEYBOARD_ROWS * cellH; + keyboardStartY = screenH - keyboardHeight; + if (keyboardStartY < 0) + keyboardStartY = 0; + } + + // Draw input area above keyboard + drawInputArea(display, offsetX, offsetY, keyboardStartY); + + // Precompute per-column x and width with leftover distributed over left columns for even spacing + int colX[KEYBOARD_COLS]; + int colW[KEYBOARD_COLS]; + int runningX = offsetX; + for (int col = 0; col < KEYBOARD_COLS - 1; ++col) { + int wcol = cellW + (col < leftoverW ? 1 : 0); + colX[col] = runningX; + colW[col] = wcol; + runningX += wcol; + } + // Last column + colX[KEYBOARD_COLS - 1] = runningX; + colW[KEYBOARD_COLS - 1] = reservedLastColW; + + // Draw keyboard grid + for (int row = 0; row < KEYBOARD_ROWS; row++) { + for (int col = 0; col < KEYBOARD_COLS; col++) { + const VirtualKey &k = keyboard[row][col]; + if (k.character != 0 || k.type != VK_CHAR) { + const bool isLastCol = (col == KEYBOARD_COLS - 1); + int x = colX[col]; + int w = colW[col]; + int y = offsetY + keyboardStartY + row * cellH; + int h = cellH; + bool selected = (row == cursorRow && col == cursorCol); + drawKey(display, k, selected, x, y, (uint8_t)w, (uint8_t)h, isLastCol); + } + } + } +} + +void VirtualKeyboard::drawInputArea(OLEDDisplay *display, int16_t offsetX, int16_t offsetY, int16_t keyboardStartY) +{ + display->setColor(WHITE); + + const int screenWidth = display->getWidth(); + const int screenHeight = display->getHeight(); + // Use the standard small font metrics for input box sizing (restore original size) + const int inputLineH = FONT_HEIGHT_SMALL; + + // Header uses the standard small (which may be larger on big screens) + display->setFont(FONT_SMALL); + int headerHeight = 0; + if (!headerText.empty()) { + // Draw header and reserve exact font height (plus a tighter gap) to maximize input area + display->drawString(offsetX + 2, offsetY, headerText.c_str()); + if (screenHeight <= 64) { + headerHeight = FONT_HEIGHT_SMALL - 2; // 11px + } else { + headerHeight = FONT_HEIGHT_SMALL; // no extra padding baked in + } + } + + const int boxX = offsetX; + const int boxWidth = screenWidth; + int boxY; + int boxHeight; + if (screenHeight <= 64) { + const int gapBelowHeader = 0; + const int fixedBoxHeight = inputLineH; + const int gapAboveKeyboard = 0; + boxY = offsetY + headerHeight + gapBelowHeader; + boxHeight = fixedBoxHeight; + if (boxY + boxHeight + gapAboveKeyboard > keyboardStartY) { + int over = boxY + boxHeight + gapAboveKeyboard - keyboardStartY; + boxHeight = std::max(1, fixedBoxHeight - over); + } + } else { + const int gapBelowHeader = 1; + int gapAboveKeyboard = 1; + int tmpBoxY = offsetY + headerHeight + gapBelowHeader; + const int minBoxHeight = inputLineH + 2; + int availableH = keyboardStartY - tmpBoxY - gapAboveKeyboard; + if (availableH < minBoxHeight) + availableH = minBoxHeight; + boxY = tmpBoxY; + boxHeight = availableH; + } + + // Draw box border + display->drawRect(boxX, boxY, boxWidth, boxHeight); + + display->setFont(FONT_SMALL); + + // Text rendering: multi-line if space allows (>= 2 lines), else single-line with leading ellipsis + const int textX = boxX + 2; + const int maxTextWidth = boxWidth - 4; + const int maxLines = (boxHeight - 2) / inputLineH; + if (maxLines >= 2) { + // Inner bounds for caret clamping + const int innerLeft = boxX + 1; + const int innerRight = boxX + boxWidth - 2; + const int innerTop = boxY + 1; + const int innerBottom = boxY + boxHeight - 2; + + // Wrap text greedily into lines that fit maxTextWidth + std::vector lines; + { + std::string remaining = inputText; + while (!remaining.empty()) { + int bestLen = 0; + for (int len = 1; len <= (int)remaining.size(); ++len) { + int w = display->getStringWidth(remaining.substr(0, len).c_str()); + if (w <= maxTextWidth) + bestLen = len; + else + break; + } + if (bestLen == 0) { + // At least show one character to make progress + bestLen = 1; + } + lines.emplace_back(remaining.substr(0, bestLen)); + remaining.erase(0, bestLen); + } + } + + const bool scrolledUp = ((int)lines.size() > maxLines); + int caretX = textX; + int caretY = innerTop; + + // Leave a small top gap to render '...' without replacing the first line + const int topInset = 2; + const int lineStep = std::max(1, inputLineH - 1); // slightly tighter than font height + int lineY = innerTop + topInset; + + if (scrolledUp) { + // Draw three small dots centered horizontally, vertically at the midpoint of the gap + // between the inner top and the first line's top baseline. This avoids using a tall glyph. + const int firstLineTop = lineY; // baseline top for the first visible line + const int gapMidY = innerTop + (firstLineTop - innerTop) / 2 + 1; // shift down 1px as requested + const int centerX = boxX + boxWidth / 2; + const int dotSpacing = 3; // px between dots + const int dotSize = 1; // small square dot + display->fillRect(centerX - dotSpacing, gapMidY, dotSize, dotSize); + display->fillRect(centerX, gapMidY, dotSize, dotSize); + display->fillRect(centerX + dotSpacing, gapMidY, dotSize, dotSize); + } + + // How many lines fit with our top inset and tighter step + const int linesCapacity = std::max(1, (innerBottom - lineY + 1) / lineStep); + const int linesToShow = std::min((int)lines.size(), linesCapacity); + const int startIndex = scrolledUp ? ((int)lines.size() - linesToShow) : 0; + + for (int i = 0; i < linesToShow; ++i) { + const std::string &chunk = lines[startIndex + i]; + display->drawString(textX, lineY, chunk.c_str()); + caretX = textX + display->getStringWidth(chunk.c_str()); + caretY = lineY; + lineY += lineStep; + } + + // Draw caret at end of the last visible line + int caretPadY = 2; + if (boxHeight >= inputLineH + 4) + caretPadY = 3; + int cursorTop = caretY + caretPadY; + // Use lineStep so caret height matches the row spacing + int cursorH = lineStep - caretPadY * 2; + if (cursorH < 1) + cursorH = 1; + // Clamp vertical bounds to stay inside the inner rect + if (cursorTop < innerTop) + cursorTop = innerTop; + if (cursorTop + cursorH - 1 > innerBottom) + cursorH = innerBottom - cursorTop + 1; + if (cursorH < 1) + cursorH = 1; + // Only draw if cursor is inside inner bounds + if (caretX >= innerLeft && caretX <= innerRight) { + display->drawVerticalLine(caretX, cursorTop, cursorH); + } + } else { + std::string displayText = inputText; + int textW = display->getStringWidth(displayText.c_str()); + std::string scrolled = displayText; + if (textW > maxTextWidth) { + // Trim from the left until it fits + while (textW > maxTextWidth && !scrolled.empty()) { + scrolled.erase(0, 1); + textW = display->getStringWidth(scrolled.c_str()); + } + // Add leading ellipsis and ensure it still fits + if (scrolled != displayText) { + scrolled = "..." + scrolled; + textW = display->getStringWidth(scrolled.c_str()); + // If adding ellipsis causes overflow, trim more after the ellipsis + while (textW > maxTextWidth && scrolled.size() > 3) { + scrolled.erase(3, 1); // remove chars after the ellipsis + textW = display->getStringWidth(scrolled.c_str()); + } + } + } else { + // Keep textW in sync with what we draw + textW = display->getStringWidth(scrolled.c_str()); + } + + int textY; + if (screenHeight <= 64) { + textY = boxY + (boxHeight - inputLineH) / 2; + } else { + const int innerLeft = boxX + 1; + const int innerRight = boxX + boxWidth - 2; + const int innerTop = boxY + 1; + const int innerBottom = boxY + boxHeight - 2; + + // Center text vertically within inner box for single-line, then clamp so it never overlaps borders + int innerH = innerBottom - innerTop + 1; + textY = innerTop + std::max(0, (innerH - inputLineH) / 2); + // Clamp fully inside the inner rect + if (textY < innerTop) + textY = innerTop; + int maxTop = innerBottom - inputLineH + 1; + if (textY > maxTop) + textY = maxTop; + } + + if (!scrolled.empty()) { + display->drawString(textX, textY, scrolled.c_str()); + } + + int cursorX = textX + textW; + if (screenHeight > 64) { + const int innerRight = boxX + boxWidth - 2; + if (cursorX > innerRight) + cursorX = innerRight; + } + + int cursorTop, cursorH; + if (screenHeight <= 64) { + cursorH = 10; + cursorTop = boxY + (boxHeight - cursorH) / 2; + } else { + const int innerLeft = boxX + 1; + const int innerRight = boxX + boxWidth - 2; + const int innerTop = boxY + 1; + const int innerBottom = boxY + boxHeight - 2; + + cursorTop = boxY + 2; + cursorH = boxHeight - 4; + if (cursorH < 1) + cursorH = 1; + if (cursorTop < innerTop) + cursorTop = innerTop; + if (cursorTop + cursorH - 1 > innerBottom) + cursorH = innerBottom - cursorTop + 1; + if (cursorH < 1) + cursorH = 1; + + if (cursorX < innerLeft || cursorX > innerRight) + return; + } + + display->drawVerticalLine(cursorX, cursorTop, cursorH); + } +} + +void VirtualKeyboard::drawKey(OLEDDisplay *display, const VirtualKey &key, bool selected, int16_t x, int16_t y, uint8_t width, + uint8_t height, bool isLastCol) +{ + // Draw key content + display->setFont(FONT_SMALL); + const int fontH = FONT_HEIGHT_SMALL; + // Build label and metrics first + std::string keyText; + if (key.type == VK_BACKSPACE || key.type == VK_ENTER || key.type == VK_SPACE || key.type == VK_ESC) { + // Keep literal text labels for the action keys on the rightmost column + keyText = (key.type == VK_BACKSPACE) ? "BACK" + : (key.type == VK_ENTER) ? "ENTER" + : (key.type == VK_SPACE) ? "SPACE" + : (key.type == VK_ESC) ? "ESC" + : ""; + } else { + char c = getCharForKey(key, false); + if (c >= 'a' && c <= 'z') { + c = c - 'a' + 'A'; + } + keyText = (key.character == ' ' || key.character == '_') ? "_" : std::string(1, c); + } + + int textWidth = display->getStringWidth(keyText.c_str()); + // Label alignment + // - Rightmost action column: right-align text with a small right padding (~2px) so it hugs screen edge neatly. + // - Other keys: center horizontally; use ceil-style rounding to avoid appearing left-biased on odd widths. + int textX; + if (isLastCol) { + const int rightPad = 1; + textX = x + width - textWidth - rightPad; + if (textX < x) + textX = x; // guard + } else { + if (display->getHeight() <= 64 && (key.character >= '0' && key.character <= '9')) { + textX = x + (width - textWidth + 1) / 2; + } else { + textX = x + (width - textWidth) / 2; + } + } + int contentTop = y; + int contentH = height; + if (selected) { + display->setColor(WHITE); + bool isAction = (key.type == VK_BACKSPACE || key.type == VK_ENTER || key.type == VK_SPACE || key.type == VK_ESC); + + if (display->getHeight() <= 64 && !isAction) { + display->fillRect(x, y, width, height); + } else if (isAction) { + const int padX = 1; + const int padY = 2; + int hlW = textWidth + padX * 2; + int hlX = textX - padX; + + if (hlX < x) { + hlW -= (x - hlX); + hlX = x; + } + int maxW = (x + width) - hlX; + if (hlW > maxW) + hlW = maxW; + if (hlW < 1) + hlW = 1; + + int hlH = std::min(fontH + padY * 2, (int)height); + int hlY = y + (height - hlH) / 2; + display->fillRect(hlX, hlY, hlW, hlH); + contentTop = hlY; + contentH = hlH; + } else { + display->fillRect(x, y, width, height); + } + display->setColor(BLACK); + } else { + display->setColor(WHITE); + } + + int centeredTextY; + if (display->getHeight() <= 64) { + centeredTextY = y + (height - fontH) / 2; + } else { + centeredTextY = contentTop + (contentH - fontH) / 2; + } + if (display->getHeight() > 64) { + if (centeredTextY < contentTop) + centeredTextY = contentTop; + if (centeredTextY + fontH > contentTop + contentH) + centeredTextY = std::max(contentTop, contentTop + contentH - fontH); + } + + if (display->getHeight() <= 64 && keyText.size() == 1) { + char ch = keyText[0]; + if (ch == '.' || ch == ',' || ch == ';') { + centeredTextY -= 1; + } + } + display->drawString(textX, centeredTextY, keyText.c_str()); +} + +char VirtualKeyboard::getCharForKey(const VirtualKey &key, bool isLongPress) +{ + if (key.type != VK_CHAR) { + return key.character; + } + + char c = key.character; + + // Long-press: only keep letter lowercase->uppercase conversion; remove other symbol mappings + if (isLongPress && c >= 'a' && c <= 'z') { + c = (char)(c - 'a' + 'A'); + } + + return c; +} + +void VirtualKeyboard::moveCursorDelta(int dRow, int dCol) +{ + resetTimeout(); + // wrap around rows and cols in the 4x11 grid + int r = (int)cursorRow + dRow; + int c = (int)cursorCol + dCol; + if (r < 0) + r = KEYBOARD_ROWS - 1; + else if (r >= KEYBOARD_ROWS) + r = 0; + if (c < 0) + c = KEYBOARD_COLS - 1; + else if (c >= KEYBOARD_COLS) + c = 0; + cursorRow = (uint8_t)r; + cursorCol = (uint8_t)c; +} + +void VirtualKeyboard::moveCursorUp() +{ + moveCursorDelta(-1, 0); +} +void VirtualKeyboard::moveCursorDown() +{ + moveCursorDelta(1, 0); +} +void VirtualKeyboard::moveCursorLeft() +{ + resetTimeout(); + + if (cursorCol > 0) { + cursorCol--; + } else { + if (cursorRow > 0) { + cursorRow--; + cursorCol = KEYBOARD_COLS - 1; + } else { + cursorRow = KEYBOARD_ROWS - 1; + cursorCol = KEYBOARD_COLS - 1; + } + } +} +void VirtualKeyboard::moveCursorRight() +{ + resetTimeout(); + + if (cursorCol < KEYBOARD_COLS - 1) { + cursorCol++; + } else { + if (cursorRow < KEYBOARD_ROWS - 1) { + cursorRow++; + cursorCol = 0; + } else { + cursorRow = 0; + cursorCol = 0; + } + } +} + +void VirtualKeyboard::handlePress() +{ + resetTimeout(); // Reset timeout on any input activity + + const VirtualKey &key = keyboard[cursorRow][cursorCol]; + + // Don't handle press if the key is empty (but allow special keys) + if (key.character == 0 && key.type == VK_CHAR) { + return; + } + + // For character keys, insert lowercase character + if (key.type == VK_CHAR) { + insertCharacter(getCharForKey(key, false)); // false = lowercase/normal char + return; + } + + // Handle non-character keys immediately + switch (key.type) { + case VK_BACKSPACE: + deleteCharacter(); + break; + case VK_ENTER: + submitText(); + break; + case VK_SPACE: + insertCharacter(' '); + break; + case VK_ESC: + if (onTextEntered) { + std::function callback = onTextEntered; + onTextEntered = nullptr; + inputText = ""; + callback(""); + } + return; + default: + break; + } +} + +void VirtualKeyboard::handleLongPress() +{ + resetTimeout(); // Reset timeout on any input activity + + const VirtualKey &key = keyboard[cursorRow][cursorCol]; + + // Don't handle press if the key is empty (but allow special keys) + if (key.character == 0 && key.type == VK_CHAR) { + return; + } + + // For character keys, insert uppercase/alternate character + if (key.type == VK_CHAR) { + insertCharacter(getCharForKey(key, true)); // true = uppercase/alternate char + return; + } + + switch (key.type) { + case VK_BACKSPACE: + // One-shot: delete up to 5 characters on long press + for (int i = 0; i < 5; ++i) { + if (inputText.empty()) + break; + deleteCharacter(); + } + break; + case VK_ENTER: + submitText(); + break; + case VK_SPACE: + insertCharacter(' '); + break; + case VK_ESC: + if (onTextEntered) { + onTextEntered(""); + } + break; + default: + break; + } +} + +void VirtualKeyboard::insertCharacter(char c) +{ + if (inputText.length() < 160) { // Reasonable text length limit + inputText += c; + } +} + +void VirtualKeyboard::deleteCharacter() +{ + if (!inputText.empty()) { + inputText.pop_back(); + } +} + +void VirtualKeyboard::submitText() +{ + LOG_INFO("Virtual keyboard: submitting text '%s'", inputText.c_str()); + + // Only submit if text is not empty + if (!inputText.empty() && onTextEntered) { + // Store callback and text to submit before clearing callback + std::function callback = onTextEntered; + std::string textToSubmit = inputText; + onTextEntered = nullptr; + // Don't clear inputText here - let the calling module handle cleanup + // inputText = ""; // Removed: keep text visible until module cleans up + callback(textToSubmit); + } else if (inputText.empty()) { + // For empty text, just ignore the submission - don't clear callback + // This keeps the virtual keyboard responsive for further input + LOG_INFO("Virtual keyboard: empty text submitted, ignoring - keyboard remains active"); + } else { + // No callback available + if (screen) { + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + } +} + +void VirtualKeyboard::setInputText(const std::string &text) +{ + inputText = text; +} + +std::string VirtualKeyboard::getInputText() const +{ + return inputText; +} + +void VirtualKeyboard::setHeader(const std::string &header) +{ + headerText = header; +} + +void VirtualKeyboard::setCallback(std::function callback) +{ + onTextEntered = callback; +} + +void VirtualKeyboard::resetTimeout() +{ + lastActivityTime = millis(); +} + +bool VirtualKeyboard::isTimedOut() const +{ + return (millis() - lastActivityTime) > TIMEOUT_MS; +} + +} // namespace graphics diff --git a/src/graphics/VirtualKeyboard.h b/src/graphics/VirtualKeyboard.h new file mode 100644 index 000000000..169163b57 --- /dev/null +++ b/src/graphics/VirtualKeyboard.h @@ -0,0 +1,80 @@ +#pragma once + +#include "configuration.h" +#include +#include +#include + +namespace graphics +{ + +enum VirtualKeyType { VK_CHAR, VK_BACKSPACE, VK_ENTER, VK_SHIFT, VK_ESC, VK_SPACE }; + +struct VirtualKey { + char character; + VirtualKeyType type; + uint8_t x; + uint8_t y; + uint8_t width; + uint8_t height; +}; + +class VirtualKeyboard +{ + public: + VirtualKeyboard(); + ~VirtualKeyboard(); + + void draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY); + void setInputText(const std::string &text); + std::string getInputText() const; + void setHeader(const std::string &header); + void setCallback(std::function callback); + + // Navigation methods for encoder input + void moveCursorUp(); + void moveCursorDown(); + void moveCursorLeft(); + void moveCursorRight(); + void handlePress(); + void handleLongPress(); + + // Timeout management + void resetTimeout(); + bool isTimedOut() const; + + private: + static const uint8_t KEYBOARD_ROWS = 4; + static const uint8_t KEYBOARD_COLS = 11; + static const uint8_t KEY_WIDTH = 9; + static const uint8_t KEY_HEIGHT = 9; // Compressed to fit 4 rows on 64px displays + static const uint8_t KEYBOARD_START_Y = 26; // Start just below input box bottom + + VirtualKey keyboard[KEYBOARD_ROWS][KEYBOARD_COLS]; + + std::string inputText; + std::string headerText; + std::function onTextEntered; + + uint8_t cursorRow; + uint8_t cursorCol; + + // Timeout management for auto-exit + uint32_t lastActivityTime; + static const uint32_t TIMEOUT_MS = 60000; // 1 minute timeout + + void initializeKeyboard(); + void drawKey(OLEDDisplay *display, const VirtualKey &key, bool selected, int16_t x, int16_t y, uint8_t w, uint8_t h, + bool isLastCol); + void drawInputArea(OLEDDisplay *display, int16_t offsetX, int16_t offsetY, int16_t keyboardStartY); + + // Unified cursor movement helper + void moveCursorDelta(int dRow, int dCol); + + char getCharForKey(const VirtualKey &key, bool isLongPress = false); + void insertCharacter(char c); + void deleteCharacter(); + void submitText(); +}; + +} // namespace graphics diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 512f650ec..e02948864 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -10,7 +10,10 @@ #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" #include "graphics/draw/UIRenderer.h" +#include "input/RotaryEncoderInterruptImpl1.h" +#include "input/UpDownInterruptImpl1.h" #include "main.h" +#include "mesh/MeshTypes.h" #include "modules/AdminModule.h" #include "modules/CannedMessageModule.h" #include "modules/KeyVerificationModule.h" diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 3d635e588..221d95075 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -38,6 +38,8 @@ bool NotificationRenderer::pauseBanner = false; notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none; uint32_t NotificationRenderer::numDigits = 0; uint32_t NotificationRenderer::currentNumber = 0; +VirtualKeyboard *NotificationRenderer::virtualKeyboard = nullptr; +std::function NotificationRenderer::textInputCallback = nullptr; uint32_t pow_of_10(uint32_t n) { @@ -89,14 +91,33 @@ void NotificationRenderer::resetBanner() void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state) { - if (!isOverlayBannerShowing() && alertBannerMessage[0] != '\0') - resetBanner(); - if (!isOverlayBannerShowing() || pauseBanner) + // Handle text_input notifications first - they have their own timeout/banner logic + if (current_notification_type == notificationTypeEnum::text_input) { + // Check for timeout and reset if needed for text input + if (millis() > alertBannerUntil && alertBannerUntil > 0) { + resetBanner(); + return; + } + drawTextInput(display, state); return; + } + + if (millis() > alertBannerUntil && alertBannerUntil > 0) { + resetBanner(); + } + + // Exit if no banner is showing or banner is paused + if (!isOverlayBannerShowing() || pauseBanner) { + return; + } + switch (current_notification_type) { case notificationTypeEnum::none: // Do nothing - no notification to display break; + case notificationTypeEnum::text_input: + // Already handled above with dedicated logic (early return). Keep a case here to satisfy -Wswitch. + break; case notificationTypeEnum::text_banner: case notificationTypeEnum::selection_picker: drawAlertBannerOverlay(display, state); @@ -575,6 +596,90 @@ void NotificationRenderer::drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUi "Please be patient and do not power off."); } +void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + if (virtualKeyboard) { + // Check for timeout and auto-exit if needed + if (virtualKeyboard->isTimedOut()) { + LOG_INFO("Virtual keyboard timeout - auto-exiting"); + // Cancel virtual keyboard - call callback with empty string to indicate timeout + auto callback = textInputCallback; // Store callback before clearing + + // Clean up first to prevent re-entry + delete virtualKeyboard; + virtualKeyboard = nullptr; + textInputCallback = nullptr; + resetBanner(); + + // Call callback after cleanup + if (callback) { + callback(""); + } + + // Restore normal overlays + if (screen) { + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + return; + } + + // Handle input events for virtual keyboard navigation + if (inEvent.inputEvent != INPUT_BROKER_NONE) { + if (inEvent.inputEvent == INPUT_BROKER_UP) { + virtualKeyboard->moveCursorUp(); + } else if (inEvent.inputEvent == INPUT_BROKER_DOWN) { + virtualKeyboard->moveCursorDown(); + } else if (inEvent.inputEvent == INPUT_BROKER_LEFT) { + virtualKeyboard->moveCursorLeft(); + } else if (inEvent.inputEvent == INPUT_BROKER_RIGHT) { + virtualKeyboard->moveCursorRight(); + } else if (inEvent.inputEvent == INPUT_BROKER_ALT_PRESS) { + // Long press UP = move left + virtualKeyboard->moveCursorLeft(); + } else if (inEvent.inputEvent == INPUT_BROKER_USER_PRESS) { + // Long press DOWN = move right + virtualKeyboard->moveCursorRight(); + } else if (inEvent.inputEvent == INPUT_BROKER_SELECT) { + virtualKeyboard->handlePress(); + } else if (inEvent.inputEvent == INPUT_BROKER_SELECT_LONG) { + virtualKeyboard->handleLongPress(); + } else if (inEvent.inputEvent == INPUT_BROKER_CANCEL) { + // Cancel virtual keyboard - call callback with empty string + auto callback = textInputCallback; // Store callback before clearing + + // Clean up first to prevent re-entry + delete virtualKeyboard; + virtualKeyboard = nullptr; + textInputCallback = nullptr; + resetBanner(); + + // Call callback after cleanup + if (callback) { + callback(""); + } + + // Restore normal overlays + if (screen) { + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + return; + } + + // Reset input event after processing + inEvent.inputEvent = INPUT_BROKER_NONE; + } + + // Clear the display and draw virtual keyboard + display->setColor(BLACK); + display->fillRect(0, 0, display->getWidth(), display->getHeight()); + display->setColor(WHITE); + virtualKeyboard->draw(display, 0, 0); + } else { + // If virtualKeyboard is null, reset the banner to avoid getting stuck + resetBanner(); + } +} + bool NotificationRenderer::isOverlayBannerShowing() { return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil); diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 9c30b329c..edb069513 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -3,6 +3,9 @@ #include "OLEDDisplay.h" #include "OLEDDisplayUi.h" #include "graphics/Screen.h" +#include "graphics/VirtualKeyboard.h" +#include +#include #define MAX_LINES 5 namespace graphics @@ -22,6 +25,8 @@ class NotificationRenderer static std::function alertBannerCallback; static uint32_t numDigits; static uint32_t currentNumber; + static VirtualKeyboard *virtualKeyboard; + static std::function textInputCallback; static bool pauseBanner; @@ -30,6 +35,7 @@ class NotificationRenderer static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state); + static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1], uint16_t totalLines, uint8_t firstOptionToShow, uint16_t maxWidth = 0); diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index 4487fa662..012a403f5 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -4,6 +4,7 @@ enum input_broker_event { INPUT_BROKER_NONE = 0, INPUT_BROKER_SELECT = 10, + INPUT_BROKER_SELECT_LONG, INPUT_BROKER_UP = 17, INPUT_BROKER_DOWN = 18, INPUT_BROKER_LEFT = 19, diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index d41ad2fd6..4c8ce6409 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -1,12 +1,14 @@ #include "TrackballInterruptBase.h" #include "configuration.h" +extern bool osk_found; TrackballInterruptBase::TrackballInterruptBase(const char *name) : concurrency::OSThread(name), _originName(name) {} void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, input_broker_event eventLeft, - input_broker_event eventRight, input_broker_event eventPressed, void (*onIntDown)(), - void (*onIntUp)(), void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()) + input_broker_event eventRight, input_broker_event eventPressed, + input_broker_event eventPressedLong, void (*onIntDown)(), void (*onIntUp)(), + void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()) { this->_pinDown = pinDown; this->_pinUp = pinUp; @@ -18,6 +20,7 @@ void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLef this->_eventLeft = eventLeft; this->_eventRight = eventRight; this->_eventPressed = eventPressed; + this->_eventPressedLong = eventPressedLong; if (pinPress != 255) { pinMode(pinPress, INPUT_PULLUP); @@ -40,9 +43,9 @@ void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLef attachInterrupt(this->_pinRight, onIntRight, TB_DIRECTION); } - LOG_DEBUG("Trackball GPIO initialized (%d, %d, %d, %d, %d)", this->_pinUp, this->_pinDown, this->_pinLeft, this->_pinRight, - pinPress); - + LOG_DEBUG("Trackball GPIO initialized - UP:%d DOWN:%d LEFT:%d RIGHT:%d PRESS:%d", this->_pinUp, this->_pinDown, + this->_pinLeft, this->_pinRight, pinPress); + osk_found = true; this->setInterval(100); } @@ -50,10 +53,47 @@ int32_t TrackballInterruptBase::runOnce() { InputEvent e; e.inputEvent = INPUT_BROKER_NONE; + + // Handle long press detection for press button + if (pressDetected && pressStartTime > 0) { + uint32_t pressDuration = millis() - pressStartTime; + bool buttonStillPressed = false; + +#if defined(T_DECK) + buttonStillPressed = (this->action == TB_ACTION_PRESSED); +#else + buttonStillPressed = !digitalRead(_pinPress); +#endif + + if (!buttonStillPressed) { + // Button released + if (pressDuration < LONG_PRESS_DURATION) { + // Short press + e.inputEvent = this->_eventPressed; + } + // Reset state + pressDetected = false; + pressStartTime = 0; + lastLongPressEventTime = 0; + this->action = TB_ACTION_NONE; + } else if (pressDuration >= LONG_PRESS_DURATION) { + // Long press detected + uint32_t currentTime = millis(); + // Only trigger long press event if enough time has passed since the last one + if (lastLongPressEventTime == 0 || (currentTime - lastLongPressEventTime) >= LONG_PRESS_REPEAT_INTERVAL) { + e.inputEvent = this->_eventPressedLong; + lastLongPressEventTime = currentTime; + } + this->action = TB_ACTION_PRESSED_LONG; + } + } + #if defined(T_DECK) // T-deck gets a super-simple debounce on trackball - if (this->action == TB_ACTION_PRESSED) { - // LOG_DEBUG("Trackball event Press"); - e.inputEvent = this->_eventPressed; + if (this->action == TB_ACTION_PRESSED && !pressDetected) { + // Start long press detection + pressDetected = true; + pressStartTime = millis(); + // Don't send event yet, wait to see if it's a long press } else if (this->action == TB_ACTION_UP && lastEvent == TB_ACTION_UP) { // LOG_DEBUG("Trackball event UP"); e.inputEvent = this->_eventUp; @@ -68,9 +108,11 @@ int32_t TrackballInterruptBase::runOnce() e.inputEvent = this->_eventRight; } #else - if (this->action == TB_ACTION_PRESSED && !digitalRead(_pinPress)) { - // LOG_DEBUG("Trackball event Press"); - e.inputEvent = this->_eventPressed; + if (this->action == TB_ACTION_PRESSED && !digitalRead(_pinPress) && !pressDetected) { + // Start long press detection + pressDetected = true; + pressStartTime = millis(); + // Don't send event yet, wait to see if it's a long press } else if (this->action == TB_ACTION_UP && !digitalRead(_pinUp)) { // LOG_DEBUG("Trackball event UP"); e.inputEvent = this->_eventUp; @@ -91,10 +133,16 @@ int32_t TrackballInterruptBase::runOnce() e.kbchar = 0x00; this->notifyObservers(&e); } - lastEvent = action; - this->action = TB_ACTION_NONE; - return 100; + // Only update lastEvent for non-press actions or completed press actions + if (this->action != TB_ACTION_PRESSED || !pressDetected) { + lastEvent = action; + if (!pressDetected) { + this->action = TB_ACTION_NONE; + } + } + + return 50; // Check more frequently for better long press detection } void TrackballInterruptBase::intPressHandler() diff --git a/src/input/TrackballInterruptBase.h b/src/input/TrackballInterruptBase.h index 92db8720e..38be22f20 100644 --- a/src/input/TrackballInterruptBase.h +++ b/src/input/TrackballInterruptBase.h @@ -18,8 +18,8 @@ class TrackballInterruptBase : public Observable, public con explicit TrackballInterruptBase(const char *name); void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, input_broker_event eventLeft, input_broker_event eventRight, - input_broker_event eventPressed, void (*onIntDown)(), void (*onIntUp)(), void (*onIntLeft)(), void (*onIntRight)(), - void (*onIntPress)()); + input_broker_event eventPressed, input_broker_event eventPressedLong, void (*onIntDown)(), void (*onIntUp)(), + void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()); void intPressHandler(); void intDownHandler(); void intUpHandler(); @@ -33,6 +33,7 @@ class TrackballInterruptBase : public Observable, public con enum TrackballInterruptBaseActionType { TB_ACTION_NONE, TB_ACTION_PRESSED, + TB_ACTION_PRESSED_LONG, TB_ACTION_UP, TB_ACTION_DOWN, TB_ACTION_LEFT, @@ -46,12 +47,20 @@ class TrackballInterruptBase : public Observable, public con volatile TrackballInterruptBaseActionType action = TB_ACTION_NONE; + // Long press detection for press button + uint32_t pressStartTime = 0; + bool pressDetected = false; + uint32_t lastLongPressEventTime = 0; + static const uint32_t LONG_PRESS_DURATION = 500; // ms + static const uint32_t LONG_PRESS_REPEAT_INTERVAL = 500; // ms - interval between repeated long press events + private: input_broker_event _eventDown = INPUT_BROKER_NONE; input_broker_event _eventUp = INPUT_BROKER_NONE; input_broker_event _eventLeft = INPUT_BROKER_NONE; input_broker_event _eventRight = INPUT_BROKER_NONE; input_broker_event _eventPressed = INPUT_BROKER_NONE; + input_broker_event _eventPressedLong = INPUT_BROKER_NONE; const char *_originName; TrackballInterruptBaseActionType lastEvent = TB_ACTION_NONE; }; diff --git a/src/input/TrackballInterruptImpl1.cpp b/src/input/TrackballInterruptImpl1.cpp index 896238f38..594facdeb 100644 --- a/src/input/TrackballInterruptImpl1.cpp +++ b/src/input/TrackballInterruptImpl1.cpp @@ -13,11 +13,12 @@ void TrackballInterruptImpl1::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLe input_broker_event eventLeft = INPUT_BROKER_LEFT; input_broker_event eventRight = INPUT_BROKER_RIGHT; input_broker_event eventPressed = INPUT_BROKER_SELECT; + input_broker_event eventPressedLong = INPUT_BROKER_SELECT_LONG; TrackballInterruptBase::init(pinDown, pinUp, pinLeft, pinRight, pinPress, eventDown, eventUp, eventLeft, eventRight, - eventPressed, TrackballInterruptImpl1::handleIntDown, TrackballInterruptImpl1::handleIntUp, - TrackballInterruptImpl1::handleIntLeft, TrackballInterruptImpl1::handleIntRight, - TrackballInterruptImpl1::handleIntPressed); + eventPressed, eventPressedLong, TrackballInterruptImpl1::handleIntDown, + TrackballInterruptImpl1::handleIntUp, TrackballInterruptImpl1::handleIntLeft, + TrackballInterruptImpl1::handleIntRight, TrackballInterruptImpl1::handleIntPressed); inputBroker->registerSource(this); } diff --git a/src/main.cpp b/src/main.cpp index ef5f5a721..23ffa6b6d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -191,6 +191,8 @@ ScanI2C::DeviceAddress cardkb_found = ScanI2C::ADDRESS_NONE; uint8_t kb_model; // global bool to record that a kb is present bool kb_found = false; +// global bool to record that on-screen keyboard (OSK) is present +bool osk_found = false; // The I2C address of the RTC Module (if found) ScanI2C::DeviceAddress rtc_found = ScanI2C::ADDRESS_NONE; @@ -1412,6 +1414,10 @@ void setup() #endif #endif +#if defined(HAS_TRACKBALL) || (defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2) + osk_found = true; +#endif + #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WEBSERVER // Start web server thread. webServerThread = new WebServerThread(); diff --git a/src/main.h b/src/main.h index 3568daad2..2ddd4862f 100644 --- a/src/main.h +++ b/src/main.h @@ -32,6 +32,7 @@ extern ScanI2C::DeviceAddress screen_found; extern ScanI2C::DeviceAddress cardkb_found; extern uint8_t kb_model; extern bool kb_found; +extern bool osk_found; extern ScanI2C::DeviceAddress rtc_found; extern ScanI2C::DeviceAddress accelerometer_found; extern ScanI2C::FoundDevice rgb_found; diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index d40dcd24f..76b950322 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -13,12 +13,16 @@ #include "detect/ScanI2C.h" #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/draw/NotificationRenderer.h" #include "graphics/emotes.h" #include "graphics/images.h" #include "main.h" // for cardkb_found #include "mesh/generated/meshtastic/cannedmessages.pb.h" #include "modules/AdminModule.h" #include "modules/ExternalNotificationModule.h" // for buzzer control +#if HAS_TRACKBALL +#include "input/TrackballInterruptImpl1.h" +#endif #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" #endif @@ -38,6 +42,7 @@ extern ScanI2C::DeviceAddress cardkb_found; extern bool graphics::isMuted; +extern bool osk_found; static const char *cannedMessagesConfigFile = "/prefs/cannedConf.proto"; static NodeNum lastDest = NODENUM_BROADCAST; @@ -151,10 +156,13 @@ int CannedMessageModule::splitConfiguredMessages() int tempCount = 0; // Insert at position 0 (top) tempMessages[tempCount++] = "[Select Destination]"; - #if defined(USE_VIRTUAL_KEYBOARD) - // Add a "Free Text" entry at the top if using a keyboard + // Add a "Free Text" entry at the top if using a touch screen virtual keyboard tempMessages[tempCount++] = "[-- Free Text --]"; +#else + if (osk_found && screen) { + tempMessages[tempCount++] = "[-- Free Text --]"; + } #endif // First message always starts at buffer start @@ -341,6 +349,8 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event) case CANNED_MESSAGE_RUN_STATE_FREETEXT: return handleFreeTextInput(event); // All allowed input for this state + // Virtual keyboard mode: Show virtual keyboard and handle input + // If sending, block all input except global/system (handled above) case CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE: return 1; @@ -627,6 +637,56 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo notifyObservers(&e); return true; } +#else + if (strcmp(current, "[-- Free Text --]") == 0) { + if (osk_found && screen) { + char headerBuffer[64]; + if (this->dest == NODENUM_BROADCAST) { + snprintf(headerBuffer, sizeof(headerBuffer), "To: Broadcast@%s", channels.getName(this->channel)); + } else { + snprintf(headerBuffer, sizeof(headerBuffer), "To: %s", getNodeName(this->dest)); + } + screen->showTextInput(headerBuffer, "", 300000, [this](const std::string &text) { + if (!text.empty()) { + this->freetext = text.c_str(); + this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT; + runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; + currentMessageIndex = -1; + + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->notifyObservers(&e); + screen->forceDisplay(); + + setIntervalFromNow(500); + return; + } else { + // Don't delete virtual keyboard immediately - it might still be executing + // Instead, just clear the callback and reset banner to stop input processing + graphics::NotificationRenderer::textInputCallback = nullptr; + graphics::NotificationRenderer::resetBanner(); + + // Return to inactive state + this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + this->currentMessageIndex = -1; + this->freetext = ""; + this->cursor = 0; + + // Force display update to show normal screen + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->notifyObservers(&e); + screen->forceDisplay(); + + // Schedule cleanup for next loop iteration to ensure safe deletion + setIntervalFromNow(50); + return; + } + }); + + return true; + } + } #endif // Normal canned message selection @@ -943,12 +1003,54 @@ int32_t CannedMessageModule::runOnce() // Normal module disable/idle handling if ((this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) || (this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE)) { + // Clean up virtual keyboard if needed when going inactive + if (graphics::NotificationRenderer::virtualKeyboard && graphics::NotificationRenderer::textInputCallback == nullptr) { + LOG_INFO("Performing delayed virtual keyboard cleanup"); + delete graphics::NotificationRenderer::virtualKeyboard; + graphics::NotificationRenderer::virtualKeyboard = nullptr; + } + temporaryMessage = ""; return INT32_MAX; } + // Handle delayed virtual keyboard message sending + if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { + // Virtual keyboard message sending case - text was not empty + if (this->freetext.length() > 0) { + LOG_INFO("Processing delayed virtual keyboard send: '%s'", this->freetext.c_str()); + sendText(this->dest, this->channel, this->freetext.c_str(), true); + + // Clean up virtual keyboard after sending + if (graphics::NotificationRenderer::virtualKeyboard) { + LOG_INFO("Cleaning up virtual keyboard after message send"); + delete graphics::NotificationRenderer::virtualKeyboard; + graphics::NotificationRenderer::virtualKeyboard = nullptr; + graphics::NotificationRenderer::textInputCallback = nullptr; + graphics::NotificationRenderer::resetBanner(); + } + + // Clear payload to indicate virtual keyboard processing is complete + // But keep SENDING_ACTIVE state to show "Sending..." screen for 2 seconds + this->payload = 0; + } else { + // Empty message, just go inactive + LOG_INFO("Empty freetext detected in delayed processing, returning to inactive state"); + this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + } + + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->currentMessageIndex = -1; + this->freetext = ""; + this->cursor = 0; + this->notifyObservers(&e); + return 2000; + } + UIFrameEvent e; - if ((this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) || + if ((this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload != 0 && + this->payload != CANNED_MESSAGE_RUN_STATE_FREETEXT) || (this->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) || (this->runState == CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION)) { this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; @@ -958,6 +1060,18 @@ int32_t CannedMessageModule::runOnce() this->freetext = ""; this->cursor = 0; this->notifyObservers(&e); + } + // Handle SENDING_ACTIVE state transition after virtual keyboard message + else if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == 0) { + // This happens after virtual keyboard message sending is complete + LOG_INFO("Virtual keyboard message sending completed, returning to inactive state"); + this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + temporaryMessage = ""; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->currentMessageIndex = -1; + this->freetext = ""; + this->cursor = 0; + this->notifyObservers(&e); } else if (((this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT)) && !Throttle::isWithinTimespanMs(this->lastTouchMillis, INACTIVATE_AFTER_MS)) { // Reset module on inactivity @@ -966,9 +1080,23 @@ int32_t CannedMessageModule::runOnce() this->freetext = ""; this->cursor = 0; this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + + // Clean up virtual keyboard if it exists during timeout + if (graphics::NotificationRenderer::virtualKeyboard) { + LOG_INFO("Cleaning up virtual keyboard due to module timeout"); + delete graphics::NotificationRenderer::virtualKeyboard; + graphics::NotificationRenderer::virtualKeyboard = nullptr; + graphics::NotificationRenderer::textInputCallback = nullptr; + graphics::NotificationRenderer::resetBanner(); + } + this->notifyObservers(&e); } else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) { - if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { + if (this->payload == 0) { + // [Exit] button pressed - return to inactive state + LOG_INFO("Processing [Exit] action - returning to inactive state"); + this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + } else if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { if (this->freetext.length() > 0) { sendText(this->dest, this->channel, this->freetext.c_str(), true); this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; From 1c329d9ffa7c8c930c712bece9e467ac72f54018 Mon Sep 17 00:00:00 2001 From: notmasteryet <146979+notmasteryet@users.noreply.github.com> Date: Thu, 21 Aug 2025 22:12:21 -0500 Subject: [PATCH 02/11] Log more information about ignored packet --- src/mesh/RadioLibInterface.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index e3ef58f14..946b1982c 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -422,7 +422,8 @@ void RadioLibInterface::handleReceiveInterrupt() } #endif if (state != RADIOLIB_ERR_NONE) { - LOG_ERROR("Ignore received packet due to error=%d", state); + LOG_ERROR("Ignore received packet due to error=%d (maybe to=0x%08x, from=0x%08x, flags=0x%02x)", state, + radioBuffer.header.to, radioBuffer.header.from, radioBuffer.header.flags); rxBad++; airTime->logAirtime(RX_ALL_LOG, xmitMsec); From 1037fa562221590d5ebaf43e3908e472eaa18380 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 23 Aug 2025 06:31:30 -0500 Subject: [PATCH 03/11] Update meshtastic/device-ui digest to 0f32b64 (#7723) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index cce4d2dcf..543205996 100644 --- a/platformio.ini +++ b/platformio.ini @@ -118,7 +118,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/3dc7cf3e233aaa8cc23492cca50541fc099ebfa1.zip + https://github.com/meshtastic/device-ui/archive/0f32b64dca418c6465763ec576509a6a2bfbc50a.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From 35f5b7ec03536875dd9810431139764ee686c4d9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 23 Aug 2025 06:48:57 -0500 Subject: [PATCH 04/11] Update caveman99-stm32-Crypto digest to 1aa30eb (#7725) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- arch/stm32/stm32.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arch/stm32/stm32.ini b/arch/stm32/stm32.ini index d91607a7d..8b7d256b3 100644 --- a/arch/stm32/stm32.ini +++ b/arch/stm32/stm32.ini @@ -50,7 +50,7 @@ lib_deps = ${radiolib_base.lib_deps} # renovate: datasource=git-refs depName=caveman99-stm32-Crypto packageName=https://github.com/caveman99/Crypto gitBranch=main - https://github.com/caveman99/Crypto/archive/eae9c768054118a9399690f8af202853d1ae8516.zip + https://github.com/caveman99/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip lib_ignore = OneButton From 4fef890466f025e5013b45f0f1c0f2d2ea6f714d Mon Sep 17 00:00:00 2001 From: Austin Date: Sat, 23 Aug 2025 11:41:57 -0400 Subject: [PATCH 05/11] Renovate: Always use `master` as the base. (#7726) --- renovate.json | 1 + 1 file changed, 1 insertion(+) diff --git a/renovate.json b/renovate.json index e90462cc3..187cdc600 100644 --- a/renovate.json +++ b/renovate.json @@ -8,6 +8,7 @@ "replacements:all", "workarounds:all" ], + "baseBranchPatterns": ["master"], "forkProcessing": "enabled", "ignoreDeps": [ "protobufs" From 103ea2f1681ee4f9b36da4f6e2888ef8c9774a54 Mon Sep 17 00:00:00 2001 From: TN <44137240+TN666@users.noreply.github.com> Date: Sun, 24 Aug 2025 20:39:50 +0800 Subject: [PATCH 06/11] Add more text message test cases for meshpacket serializer (#7709) * Add more text message test cases for meshpacket serializer * fix the trunk issue --- .../ports/test_text_message.cpp | 127 +++++++++++++----- .../test_serializer.cpp | 8 ++ 2 files changed, 103 insertions(+), 32 deletions(-) diff --git a/test/test_meshpacket_serializer/ports/test_text_message.cpp b/test/test_meshpacket_serializer/ports/test_text_message.cpp index de3f34541..6213b08d5 100644 --- a/test/test_meshpacket_serializer/ports/test_text_message.cpp +++ b/test/test_meshpacket_serializer/ports/test_text_message.cpp @@ -1,42 +1,105 @@ #include "../test_helpers.h" +#include + +// Helper function to test common packet fields and structure +void verify_text_message_packet_structure(const std::string &json, const char *expected_text) +{ + TEST_ASSERT_TRUE(json.length() > 0); + + // Use smart pointer for automatic memory management + std::unique_ptr root(JSON::Parse(json.c_str())); + TEST_ASSERT_NOT_NULL(root.get()); + TEST_ASSERT_TRUE(root->IsObject()); + + JSONObject jsonObj = root->AsObject(); + + // Check basic packet fields - use helper function to reduce duplication + auto check_field = [&](const char *field, uint32_t expected_value) { + auto it = jsonObj.find(field); + TEST_ASSERT_TRUE(it != jsonObj.end()); + TEST_ASSERT_EQUAL(expected_value, (uint32_t)it->second->AsNumber()); + }; + + check_field("from", 0x11223344); + check_field("to", 0x55667788); + check_field("id", 0x9999); + + // Check message type + auto type_it = jsonObj.find("type"); + TEST_ASSERT_TRUE(type_it != jsonObj.end()); + TEST_ASSERT_EQUAL_STRING("text", type_it->second->AsString().c_str()); + + // Check payload + auto payload_it = jsonObj.find("payload"); + TEST_ASSERT_TRUE(payload_it != jsonObj.end()); + TEST_ASSERT_TRUE(payload_it->second->IsObject()); + + JSONObject payload = payload_it->second->AsObject(); + auto text_it = payload.find("text"); + TEST_ASSERT_TRUE(text_it != payload.end()); + TEST_ASSERT_EQUAL_STRING(expected_text, text_it->second->AsString().c_str()); + + // No need for manual delete with smart pointer +} // Test TEXT_MESSAGE_APP port void test_text_message_serialization() { const char *test_text = "Hello Meshtastic!"; meshtastic_MeshPacket packet = - create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, (const uint8_t *)test_text, strlen(test_text)); + create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, reinterpret_cast(test_text), strlen(test_text)); std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); - TEST_ASSERT_TRUE(json.length() > 0); - - JSONValue *root = JSON::Parse(json.c_str()); - TEST_ASSERT_NOT_NULL(root); - TEST_ASSERT_TRUE(root->IsObject()); - - JSONObject jsonObj = root->AsObject(); - - // Check basic packet fields - TEST_ASSERT_TRUE(jsonObj.find("from") != jsonObj.end()); - TEST_ASSERT_EQUAL(0x11223344, (uint32_t)jsonObj["from"]->AsNumber()); - - TEST_ASSERT_TRUE(jsonObj.find("to") != jsonObj.end()); - TEST_ASSERT_EQUAL(0x55667788, (uint32_t)jsonObj["to"]->AsNumber()); - - TEST_ASSERT_TRUE(jsonObj.find("id") != jsonObj.end()); - TEST_ASSERT_EQUAL(0x9999, (uint32_t)jsonObj["id"]->AsNumber()); - - // Check message type - TEST_ASSERT_TRUE(jsonObj.find("type") != jsonObj.end()); - TEST_ASSERT_EQUAL_STRING("text", jsonObj["type"]->AsString().c_str()); - - // Check payload - TEST_ASSERT_TRUE(jsonObj.find("payload") != jsonObj.end()); - TEST_ASSERT_TRUE(jsonObj["payload"]->IsObject()); - - JSONObject payload = jsonObj["payload"]->AsObject(); - TEST_ASSERT_TRUE(payload.find("text") != payload.end()); - TEST_ASSERT_EQUAL_STRING("Hello Meshtastic!", payload["text"]->AsString().c_str()); - - delete root; + verify_text_message_packet_structure(json, test_text); +} + +// Test with nullptr to check robustness +void test_text_message_serialization_null() +{ + meshtastic_MeshPacket packet = create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, nullptr, 0); + + std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); + verify_text_message_packet_structure(json, ""); +} + +// Test TEXT_MESSAGE_APP port with very long message (boundary testing) +void test_text_message_serialization_long_text() +{ + // Test with actual message size limits + constexpr size_t MAX_MESSAGE_SIZE = 200; // Typical LoRa payload limit + std::string long_text(MAX_MESSAGE_SIZE, 'A'); + + meshtastic_MeshPacket packet = create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, + reinterpret_cast(long_text.c_str()), long_text.length()); + + std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); + verify_text_message_packet_structure(json, long_text.c_str()); +} + +// Test with message over size limit (should fail) +void test_text_message_serialization_oversized() +{ + constexpr size_t OVERSIZED_MESSAGE = 250; // Over the limit + std::string oversized_text(OVERSIZED_MESSAGE, 'B'); + + meshtastic_MeshPacket packet = create_test_packet( + meshtastic_PortNum_TEXT_MESSAGE_APP, reinterpret_cast(oversized_text.c_str()), oversized_text.length()); + + // Should fail or return empty/error + std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); + // Should only verify first 234 characters for oversized messages + std::string expected_text = oversized_text.substr(0, 234); + verify_text_message_packet_structure(json, expected_text.c_str()); +} + +// Add test for malformed UTF-8 sequences +void test_text_message_serialization_invalid_utf8() +{ + const uint8_t invalid_utf8[] = {0xFF, 0xFE, 0xFD, 0x00}; // Invalid UTF-8 + meshtastic_MeshPacket packet = + create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, invalid_utf8, sizeof(invalid_utf8) - 1); + + // Should not crash, may produce replacement characters + std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); + TEST_ASSERT_TRUE(json.length() > 0); } diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp index d74031fa4..7f51a2e70 100644 --- a/test/test_meshpacket_serializer/test_serializer.cpp +++ b/test/test_meshpacket_serializer/test_serializer.cpp @@ -4,6 +4,10 @@ // Forward declarations for test functions void test_text_message_serialization(); +void test_text_message_serialization_null(); +void test_text_message_serialization_long_text(); +void test_text_message_serialization_oversized(); +void test_text_message_serialization_invalid_utf8(); void test_position_serialization(); void test_nodeinfo_serialization(); void test_waypoint_serialization(); @@ -21,6 +25,10 @@ void setup() // Text message tests RUN_TEST(test_text_message_serialization); + RUN_TEST(test_text_message_serialization_null); + RUN_TEST(test_text_message_serialization_long_text); + RUN_TEST(test_text_message_serialization_oversized); + RUN_TEST(test_text_message_serialization_invalid_utf8); // Position tests RUN_TEST(test_position_serialization); From 1eafdfcbc88f32a657c1e0d76b4cb36d15072578 Mon Sep 17 00:00:00 2001 From: Tom Fifield Date: Mon, 25 Aug 2025 05:45:29 +1000 Subject: [PATCH 07/11] Reduce power of EU433 to 10dBm (#7733) We are currently blocked from making the breaking change to fix EU_433 channel centres until 3.0 (https://github.com/meshtastic/firmware/issues/3371 ) However, as already updated in https://github.com/meshtastic/meshtastic/pull/919 the documentation, the power limit for EU_433 is 10dBm. We can change the power limit without breaking anything, so this patch sets the power limit to match the ETSI spec without changing any other settings. --- src/mesh/RadioInterface.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index c210d5d48..e721431b1 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -32,9 +32,12 @@ const RegionInfo regions[] = { RDEF(US, 902.0f, 928.0f, 100, 0, 30, true, false, false), /* - https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf + EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21] + + https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf + FIXME: https://github.com/meshtastic/firmware/issues/3371 */ - RDEF(EU_433, 433.0f, 434.0f, 10, 0, 12, true, false, false), + RDEF(EU_433, 433.0f, 434.0f, 10, 0, 10, true, false, false), /* https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ From 5b9db81819f45b625683047c3b78bfece8d23b2e Mon Sep 17 00:00:00 2001 From: TN666 Date: Mon, 25 Aug 2025 23:35:03 +0800 Subject: [PATCH 08/11] Add more test case for encrypted packet test --- .../ports/test_encrypted.cpp | 74 +++++++++++++------ .../test_serializer.cpp | 2 + 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/test/test_meshpacket_serializer/ports/test_encrypted.cpp b/test/test_meshpacket_serializer/ports/test_encrypted.cpp index 557ee7a49..24866654a 100644 --- a/test/test_meshpacket_serializer/ports/test_encrypted.cpp +++ b/test/test_meshpacket_serializer/ports/test_encrypted.cpp @@ -1,20 +1,32 @@ #include "../test_helpers.h" -// Test encrypted packet serialization -void test_encrypted_packet_serialization() +// test data initialization +const int from = 0x11223344; +const int to = 0x55667788; +const int id = 0x9999; + +// Helper function to create a test encrypted packet +meshtastic_MeshPacket create_test_encrypted_packet(uint32_t from, uint32_t to, uint32_t id, const char *data) { meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; - packet.from = 0x11223344; - packet.to = 0x55667788; - packet.id = 0x9999; + packet.from = from; + packet.to = to; + packet.id = id; packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; - // Add some dummy encrypted data - const char *encrypted_data = "encrypted_payload_data"; - packet.encrypted.size = strlen(encrypted_data); - memcpy(packet.encrypted.bytes, encrypted_data, packet.encrypted.size); + if (data) { + packet.encrypted.size = strlen(data); + memcpy(packet.encrypted.bytes, data, packet.encrypted.size); + } - std::string json = MeshPacketSerializer::JsonSerializeEncrypted(&packet); + return packet; +} + +// Comprehensive helper function for all encrypted packet assertions +void assert_encrypted_packet(const std::string &json, uint32_t expected_from, uint32_t expected_to, uint32_t expected_id, + size_t expected_size) +{ + // Parse and validate JSON TEST_ASSERT_TRUE(json.length() > 0); JSONValue *root = JSON::Parse(json.c_str()); @@ -23,28 +35,48 @@ void test_encrypted_packet_serialization() JSONObject jsonObj = root->AsObject(); - // Check basic packet fields + // Assert basic packet fields TEST_ASSERT_TRUE(jsonObj.find("from") != jsonObj.end()); - TEST_ASSERT_EQUAL(0x11223344, (uint32_t)jsonObj["from"]->AsNumber()); + TEST_ASSERT_EQUAL(expected_from, (uint32_t)jsonObj.at("from")->AsNumber()); TEST_ASSERT_TRUE(jsonObj.find("to") != jsonObj.end()); - TEST_ASSERT_EQUAL(0x55667788, (uint32_t)jsonObj["to"]->AsNumber()); + TEST_ASSERT_EQUAL(expected_to, (uint32_t)jsonObj.at("to")->AsNumber()); TEST_ASSERT_TRUE(jsonObj.find("id") != jsonObj.end()); - TEST_ASSERT_EQUAL(0x9999, (uint32_t)jsonObj["id"]->AsNumber()); + TEST_ASSERT_EQUAL(expected_id, (uint32_t)jsonObj.at("id")->AsNumber()); - // Check that it has encrypted data fields (not "payload" but "bytes" and "size") + // Assert encrypted data fields TEST_ASSERT_TRUE(jsonObj.find("bytes") != jsonObj.end()); - TEST_ASSERT_TRUE(jsonObj["bytes"]->IsString()); + TEST_ASSERT_TRUE(jsonObj.at("bytes")->IsString()); TEST_ASSERT_TRUE(jsonObj.find("size") != jsonObj.end()); - TEST_ASSERT_EQUAL(22, (int)jsonObj["size"]->AsNumber()); // strlen("encrypted_payload_data") = 22 + TEST_ASSERT_EQUAL(expected_size, (int)jsonObj.at("size")->AsNumber()); - // The encrypted data should be hex-encoded + // Assert hex encoding std::string encrypted_hex = jsonObj["bytes"]->AsString(); - TEST_ASSERT_TRUE(encrypted_hex.length() > 0); - // Should be twice the size of the original data (hex encoding) - TEST_ASSERT_EQUAL(44, encrypted_hex.length()); // 22 * 2 = 44 + TEST_ASSERT_EQUAL(expected_size * 2, encrypted_hex.length()); delete root; } + +// Test encrypted packet serialization +void test_encrypted_packet_serialization() +{ + const char *data = "encrypted_payload_data"; + + meshtastic_MeshPacket packet = create_test_encrypted_packet(from, to, id, data); + std::string json = MeshPacketSerializer::JsonSerializeEncrypted(&packet); + + assert_encrypted_packet(json, from, to, id, strlen(data)); +} + +// Test empty encrypted packet +void test_empty_encrypted_packet() +{ + const char *data = ""; + + meshtastic_MeshPacket packet = create_test_encrypted_packet(from, to, id, data); + std::string json = MeshPacketSerializer::JsonSerializeEncrypted(&packet); + + assert_encrypted_packet(json, from, to, id, strlen(data)); +} diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp index 7f51a2e70..484db8d74 100644 --- a/test/test_meshpacket_serializer/test_serializer.cpp +++ b/test/test_meshpacket_serializer/test_serializer.cpp @@ -18,6 +18,7 @@ void test_telemetry_environment_metrics_missing_fields(); void test_telemetry_environment_metrics_complete_coverage(); void test_telemetry_environment_metrics_unset_fields(); void test_encrypted_packet_serialization(); +void test_empty_encrypted_packet(); void setup() { @@ -49,6 +50,7 @@ void setup() // Encrypted packet test RUN_TEST(test_encrypted_packet_serialization); + RUN_TEST(test_empty_encrypted_packet); UNITY_END(); } From 9a1c2c9b61fd06c03809adb860d249c2f715a712 Mon Sep 17 00:00:00 2001 From: m1nl Date: Mon, 25 Aug 2025 19:42:13 +0200 Subject: [PATCH 09/11] setup flags which describe framework / device PM capabilties --- src/platform/esp32/architecture.h | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 522e862ac..4373b2cf0 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -215,3 +215,13 @@ #endif #define SERIAL0_RX_GPIO 3 // Always GPIO3 on ESP32 // FIXME: may be different on ESP32-S3, etc. + +// Setup flag, which indicates if our device supports power management +#ifdef CONFIG_PM_ENABLE +#define HAS_ESP32_PM_SUPPORT 1 +#endif + +// Setup flag, which indicates if our device supports dynamic light sleep +#if defined(HAS_ESP32_PM_SUPPORT) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) +#define HAS_ESP32_DYNAMIC_LIGHT_SLEEP 1 +#endif \ No newline at end of file From ba26d03b1b941c57998d29ea448931b4f5aeb8f0 Mon Sep 17 00:00:00 2001 From: m1nl Date: Mon, 25 Aug 2025 19:44:13 +0200 Subject: [PATCH 10/11] standarize values of HAS_32768HZ capability flag --- src/platform/esp32/architecture.h | 3 +++ src/platform/esp32/main-esp32.cpp | 10 +++++----- variants/esp32s3/tbeam-s3-core/variant.h | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 4373b2cf0..80749ee6b 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -45,6 +45,9 @@ #ifndef HAS_CUSTOM_CRYPTO_ENGINE #define HAS_CUSTOM_CRYPTO_ENGINE 1 #endif +#ifndef HAS_32768HZ +#define HAS_32768HZ 0 +#endif #if defined(HAS_AXP192) || defined(HAS_AXP2101) #define HAS_PMU diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index cdea53c9a..760964119 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -64,7 +64,7 @@ void getMacAddr(uint8_t *dmac) #endif } -#ifdef HAS_32768HZ +#if HAS_32768HZ #define CALIBRATE_ONE(cali_clk) calibrate_one(cali_clk, #cali_clk) static uint32_t calibrate_one(rtc_cal_sel_t cal_clk, const char *name) @@ -86,17 +86,17 @@ void enableSlowCLK() uint32_t cal_32k = CALIBRATE_ONE(RTC_CAL_32K_XTAL); if (cal_32k == 0) { - LOG_DEBUG("32K XTAL OSC has not started up"); + LOG_DEBUG("32k XTAL OSC has not started up"); } else { rtc_clk_slow_freq_set(RTC_SLOW_FREQ_32K_XTAL); - LOG_DEBUG("Switch RTC Source to 32.768Khz succeeded, using 32K XTAL"); + LOG_DEBUG("Switch RTC Source to 32.768kHz succeeded, using 32k XTAL"); CALIBRATE_ONE(RTC_CAL_RTC_MUX); CALIBRATE_ONE(RTC_CAL_32K_XTAL); } CALIBRATE_ONE(RTC_CAL_RTC_MUX); CALIBRATE_ONE(RTC_CAL_32K_XTAL); if (rtc_clk_slow_freq_get() != RTC_SLOW_FREQ_32K_XTAL) { - LOG_WARN("Failed to switch 32K XTAL RTC source to 32.768Khz !!! "); + LOG_WARN("Failed to switch 32K XTAL RTC source to 32.768kHz !!! "); return; } } @@ -182,7 +182,7 @@ void esp32Setup() res = esp_task_wdt_add(NULL); assert(res == ESP_OK); -#ifdef HAS_32768HZ +#if HAS_32768HZ enableSlowCLK(); #endif } diff --git a/variants/esp32s3/tbeam-s3-core/variant.h b/variants/esp32s3/tbeam-s3-core/variant.h index dabd52980..40ba0307a 100644 --- a/variants/esp32s3/tbeam-s3-core/variant.h +++ b/variants/esp32s3/tbeam-s3-core/variant.h @@ -62,6 +62,6 @@ // #define PCF8563_RTC 0x51 //Putting definitions in variant. h does not compile correctly // has 32768 Hz crystal -#define HAS_32768HZ +#define HAS_32768HZ 1 -#define USE_SH1106 \ No newline at end of file +#define USE_SH1106 From 5aa486d6c2e5f284b6530a4bbaf95902738e71cc Mon Sep 17 00:00:00 2001 From: m1nl Date: Mon, 25 Aug 2025 19:56:15 +0200 Subject: [PATCH 11/11] set HAS_32768HZ for Heltec V3 board --- variants/esp32s3/heltec_v3/variant.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/esp32s3/heltec_v3/variant.h b/variants/esp32s3/heltec_v3/variant.h index 4f1d91db8..d760c3b7f 100644 --- a/variants/esp32s3/heltec_v3/variant.h +++ b/variants/esp32s3/heltec_v3/variant.h @@ -40,3 +40,5 @@ #define SX126X_DIO2_AS_RF_SWITCH #define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define HAS_32768HZ 1 \ No newline at end of file