diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index c1bd1d6..154481f 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -4,6 +4,22 @@ #include "variant.h" // Board-specific defines (HAS_GPS, etc.) #include "target.h" // For sensors, board, etc. +// T-Deck Pro Keyboard support +#if defined(LilyGo_TDeck_Pro) + #include "TCA8418Keyboard.h" + TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); + + // Compose mode state + static bool composeMode = false; + static char composeBuffer[138]; // 137 chars max + null terminator + static int composePos = 0; + + void initKeyboard(); + void handleKeyboardInput(); + void drawComposeScreen(); + void sendComposedMessage(); +#endif + // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { uint32_t n = 0; @@ -303,6 +319,11 @@ void setup() { MESH_DEBUG_PRINTLN("setup() - ui_task.begin() done"); #endif + // Initialize T-Deck Pro keyboard + #if defined(LilyGo_TDeck_Pro) + initKeyboard(); + #endif + // Enable GPS by default on T-Deck Pro #if HAS_GPS // Set GPS enabled in both sensor manager and node prefs @@ -319,7 +340,232 @@ void loop() { the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS + // Skip UITask rendering when in compose mode to prevent flickering + #if defined(LilyGo_TDeck_Pro) + if (!composeMode) { + ui_task.loop(); + } + #else ui_task.loop(); + #endif #endif rtc_clock.tick(); -} \ No newline at end of file + + // Handle T-Deck Pro keyboard input + #if defined(LilyGo_TDeck_Pro) + handleKeyboardInput(); + #endif +} + +// ============================================================================ +// T-DECK PRO KEYBOARD FUNCTIONS +// ============================================================================ + +#if defined(LilyGo_TDeck_Pro) + +void initKeyboard() { + // Keyboard uses the same I2C bus as other peripherals (already initialized) + if (keyboard.begin()) { + MESH_DEBUG_PRINTLN("setup() - Keyboard initialized"); + composeBuffer[0] = '\0'; + composePos = 0; + composeMode = false; + } else { + MESH_DEBUG_PRINTLN("setup() - Keyboard initialization failed!"); + } +} + +void handleKeyboardInput() { + if (!keyboard.isReady()) return; + + char key = keyboard.readKey(); + if (key == 0) return; + + Serial.printf("handleKeyboardInput: key='%c' (0x%02X) composeMode=%d\n", + key >= 32 ? key : '?', key, composeMode); + + if (composeMode) { + // In compose mode - handle text input + if (key == '\r') { + // Enter - send the message + Serial.println("Compose: Enter pressed, sending..."); + if (composePos > 0) { + sendComposedMessage(); + } + composeMode = false; + composeBuffer[0] = '\0'; + composePos = 0; + ui_task.gotoHomeScreen(); + return; + } + + if (key == '\b') { + // Backspace - check if shift was recently pressed for cancel combo + if (keyboard.wasShiftRecentlyPressed(500)) { + // Shift+Backspace = Cancel + Serial.println("Compose: Shift+Backspace, cancelling..."); + composeMode = false; + composeBuffer[0] = '\0'; + composePos = 0; + ui_task.gotoHomeScreen(); + return; + } + // Regular backspace - delete last character + if (composePos > 0) { + composePos--; + composeBuffer[composePos] = '\0'; + Serial.printf("Compose: Backspace, pos now %d\n", composePos); + drawComposeScreen(); + } + return; + } + + // Regular character input + if (key >= 32 && key < 127 && composePos < 137) { + composeBuffer[composePos++] = key; + composeBuffer[composePos] = '\0'; + Serial.printf("Compose: Added '%c', pos now %d\n", key, composePos); + drawComposeScreen(); + } + return; + } + + // Normal mode - not composing + switch (key) { + case 'c': + case 'C': + // Enter compose mode + composeMode = true; + composeBuffer[0] = '\0'; + composePos = 0; + Serial.println("Entering compose mode"); + drawComposeScreen(); + break; + + case 'm': + case 'M': + // Go to channel message screen + Serial.println("Opening channel messages"); + ui_task.gotoChannelScreen(); + break; + + case 'w': + case 'W': + case 'a': + case 'A': + // Navigate left/previous + Serial.println("Nav: Previous"); + ui_task.injectKey(0xF2); // KEY_PREV + break; + + case 's': + case 'S': + case 'd': + case 'D': + // Navigate right/next + Serial.println("Nav: Next"); + ui_task.injectKey(0xF1); // KEY_NEXT + break; + + case '\r': + // Select/Enter + Serial.println("Nav: Enter/Select"); + ui_task.injectKey(13); // KEY_ENTER + break; + + case 'q': + case 'Q': + case '\b': + // Go back to home screen + Serial.println("Nav: Back to home"); + ui_task.gotoHomeScreen(); + break; + + case ' ': + // Space - also acts as next/select + Serial.println("Nav: Space (Next)"); + ui_task.injectKey(0xF1); // KEY_NEXT + break; + + default: + Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key); + break; + } +} + +void drawComposeScreen() { + #ifdef DISPLAY_CLASS + display.startFrame(); + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print("Compose - Public Channel"); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, display.width(), 1); + + display.setCursor(0, 14); + display.setColor(DisplayDriver::LIGHT); + + // Word wrap the compose buffer + int x = 0; + int y = 14; + int charsPerLine = display.width() / 6; + char charStr[2] = {0, 0}; // Buffer for single character as string + + for (int i = 0; i < composePos; i++) { + charStr[0] = composeBuffer[i]; + display.print(charStr); + x++; + if (x >= charsPerLine) { + x = 0; + y += 11; + display.setCursor(0, y); + } + } + + // Show cursor + display.print("_"); + + // Status bar + int statusY = display.height() - 12; + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, statusY - 2, display.width(), 1); + display.setCursor(0, statusY); + display.setColor(DisplayDriver::YELLOW); + + char status[50]; + sprintf(status, "%d/137 Enter:Send Sh+Del:Cancel", composePos); + display.print(status); + + display.endFrame(); + #endif +} + +void sendComposedMessage() { + if (composePos == 0) return; + + MESH_DEBUG_PRINTLN("Sending message: %s", composeBuffer); + + // Get the Public channel (index 0) + ChannelDetails channel; + if (the_mesh.getChannel(0, channel)) { + uint32_t timestamp = rtc_clock.getCurrentTime(); + + // Send to channel + if (the_mesh.sendGroupMessage(timestamp, channel.channel, + the_mesh.getNodePrefs()->node_name, + composeBuffer, composePos)) { + MESH_DEBUG_PRINTLN("Message sent to Public channel"); + ui_task.showAlert("Sent!", 1500); + } else { + MESH_DEBUG_PRINTLN("Failed to send message"); + ui_task.showAlert("Send failed!", 1500); + } + } else { + MESH_DEBUG_PRINTLN("Could not get Public channel"); + ui_task.showAlert("No channel!", 1500); + } +} + +#endif // LilyGo_TDeck_Pro \ No newline at end of file diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h new file mode 100644 index 0000000..81eafa3 --- /dev/null +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -0,0 +1,238 @@ +#pragma once + +#include +#include +#include + +// Maximum messages to store in history +#define CHANNEL_MSG_HISTORY_SIZE 20 +#define CHANNEL_MSG_TEXT_LEN 160 + +class UITask; // Forward declaration + +class ChannelScreen : public UIScreen { +public: + struct ChannelMessage { + uint32_t timestamp; + uint8_t path_len; + char text[CHANNEL_MSG_TEXT_LEN]; + bool valid; + }; + +private: + UITask* _task; + mesh::RTCClock* _rtc; + + ChannelMessage _messages[CHANNEL_MSG_HISTORY_SIZE]; + int _msgCount; // Total messages stored + int _newestIdx; // Index of newest message (circular buffer) + int _scrollPos; // Current scroll position (0 = newest) + int _msgsPerPage; // Messages that fit on screen + +public: + ChannelScreen(UITask* task, mesh::RTCClock* rtc) + : _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0), _msgsPerPage(3) { + // Initialize all messages as invalid + for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) { + _messages[i].valid = false; + } + } + + // Add a new message to the history + void addMessage(uint8_t path_len, const char* sender, const char* text) { + // Move to next slot in circular buffer + _newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE; + + ChannelMessage* msg = &_messages[_newestIdx]; + msg->timestamp = _rtc->getCurrentTime(); + msg->path_len = path_len; + msg->valid = true; + + // The text already contains "Sender: message" format, just store it + strncpy(msg->text, text, CHANNEL_MSG_TEXT_LEN - 1); + msg->text[CHANNEL_MSG_TEXT_LEN - 1] = '\0'; + + if (_msgCount < CHANNEL_MSG_HISTORY_SIZE) { + _msgCount++; + } + + // Reset scroll to show newest message + _scrollPos = 0; + } + + int getMessageCount() const { return _msgCount; } + + int render(DisplayDriver& display) override { + char tmp[32]; + + // Header + display.setCursor(0, 0); + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.print("Public Channel"); + + // Message count on right + sprintf(tmp, "[%d]", _msgCount); + display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); + display.print(tmp); + + // Divider line + display.drawRect(0, 11, display.width(), 1); + + if (_msgCount == 0) { + display.setCursor(0, 25); + display.setColor(DisplayDriver::LIGHT); + display.print("No messages yet"); + display.setCursor(0, 40); + display.print("Press C to compose"); + } else { + int lineHeight = 10; + int headerHeight = 14; + int footerHeight = 14; + int availableHeight = display.height() - headerHeight - footerHeight; + + // Calculate chars per line based on display width + int charsPerLine = display.width() / 6; + + int y = headerHeight; + + // Display messages from scroll position + int msgsDrawn = 0; + for (int i = 0; i + _scrollPos < _msgCount && y < display.height() - footerHeight - lineHeight; i++) { + // Calculate index in circular buffer + int idx = _newestIdx - _scrollPos - i; + while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE; + idx = idx % CHANNEL_MSG_HISTORY_SIZE; + + if (!_messages[idx].valid) continue; + + ChannelMessage* msg = &_messages[idx]; + + // Time indicator with hop count + display.setCursor(0, y); + display.setColor(DisplayDriver::YELLOW); + + uint32_t age = _rtc->getCurrentTime() - msg->timestamp; + if (age < 60) { + sprintf(tmp, "(%d) %ds", msg->path_len == 0xFF ? 0 : msg->path_len, age); + } else if (age < 3600) { + sprintf(tmp, "(%d) %dm", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60); + } else if (age < 86400) { + sprintf(tmp, "(%d) %dh", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600); + } else { + sprintf(tmp, "(%d) %dd", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400); + } + display.print(tmp); + y += lineHeight; + + // Message text with word wrap - the text already contains "Sender: message" + display.setColor(DisplayDriver::LIGHT); + + int textLen = strlen(msg->text); + int pos = 0; + int linesForThisMsg = 0; + int maxLinesPerMsg = 3; // Allow up to 3 lines per message + + while (pos < textLen && linesForThisMsg < maxLinesPerMsg && y < display.height() - footerHeight - 2) { + display.setCursor(0, y); + + // Find how much text fits on this line + int lineEnd = pos + charsPerLine; + if (lineEnd >= textLen) { + lineEnd = textLen; + } else { + // Try to break at a space + int lastSpace = -1; + for (int j = pos; j < lineEnd && j < textLen; j++) { + if (msg->text[j] == ' ') lastSpace = j; + } + if (lastSpace > pos) lineEnd = lastSpace; + } + + // Print this line segment + char lineBuf[42]; + int lineLen = lineEnd - pos; + if (lineLen > 40) lineLen = 40; + strncpy(lineBuf, msg->text + pos, lineLen); + lineBuf[lineLen] = '\0'; + display.print(lineBuf); + + pos = lineEnd; + // Skip space at start of next line + while (pos < textLen && msg->text[pos] == ' ') pos++; + + y += lineHeight; + linesForThisMsg++; + } + + // If we truncated, show ellipsis indicator + if (pos < textLen && linesForThisMsg >= maxLinesPerMsg) { + // Message was truncated - could add "..." but space is tight + } + + y += 2; // Small gap between messages + msgsDrawn++; + _msgsPerPage = msgsDrawn; + } + } + + // Footer with scroll indicator and controls + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setCursor(0, footerY); + display.setColor(DisplayDriver::YELLOW); + + // Left side: Q:Exit + display.print("Q:Exit"); + + // Middle: scroll position indicator + if (_msgCount > _msgsPerPage) { + int endMsg = _scrollPos + _msgsPerPage; + if (endMsg > _msgCount) endMsg = _msgCount; + sprintf(tmp, "%d-%d/%d", _scrollPos + 1, endMsg, _msgCount); + // Center it roughly + int midX = display.width() / 2 - 15; + display.setCursor(midX, footerY); + display.print(tmp); + } + + // Right side: controls + sprintf(tmp, "W/S:Scrl C:New"); + display.setCursor(display.width() - display.getTextWidth(tmp) - 2, footerY); + display.print(tmp); + +#if AUTO_OFF_MILLIS == 0 // e-ink + return 5000; // Refresh every 5s +#else + return 1000; // Refresh every 1s for time updates +#endif + } + + bool handleInput(char c) override { + // KEY_PREV (0xF2) or 'w' - scroll up (older messages) + if (c == 0xF2 || c == 'w' || c == 'W') { + if (_scrollPos + _msgsPerPage < _msgCount) { + _scrollPos++; + return true; + } + } + + // KEY_NEXT (0xF1) or 's' - scroll down (newer messages) + if (c == 0xF1 || c == 's' || c == 'S') { + if (_scrollPos > 0) { + _scrollPos--; + return true; + } + } + + // KEY_ENTER or 'c' - compose (handled by main.cpp keyboard handler) + // 'q' - go back (handled by main.cpp keyboard handler) + + return false; + } + + // Reset scroll position to newest + void resetScroll() { + _scrollPos = 0; + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index aafb6f9..b343911 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -30,6 +30,7 @@ #endif #include "icons.h" +#include "ChannelScreen.h" class SplashScreen : public UIScreen { UITask* _task; @@ -580,6 +581,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); msg_preview = new MsgPreviewScreen(this, &rtc_clock); + channel_screen = new ChannelScreen(this, &rtc_clock); setCurrScreen(splash); } @@ -628,7 +630,12 @@ void UITask::msgRead(int msgcount) { void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { _msgcount = msgcount; + // Add to preview screen (for notifications) ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text); + + // Also add to channel history screen + ((ChannelScreen *) channel_screen)->addMessage(path_len, from_name, text); + setCurrScreen(msg_preview); if (_display != NULL) { @@ -922,3 +929,25 @@ void UITask::toggleBuzzer() { _next_refresh = 0; // trigger refresh #endif } + +void UITask::injectKey(char c) { + if (c != 0 && curr) { + // Turn on display if it's off + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + curr->handleInput(c); + _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + _next_refresh = 100; // trigger refresh + } +} + +void UITask::gotoChannelScreen() { + ((ChannelScreen *) channel_screen)->resetScroll(); + setCurrScreen(channel_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 02c3caf..bda85df 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -51,6 +51,7 @@ class UITask : public AbstractUITask { UIScreen* splash; UIScreen* home; UIScreen* msg_preview; + UIScreen* channel_screen; // Channel message history screen UIScreen* curr; void userLedHandler(); @@ -73,15 +74,23 @@ public: void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); void gotoHomeScreen() { setCurrScreen(home); } + void gotoChannelScreen(); // Navigate to channel message screen void showAlert(const char* text, int duration_millis); int getMsgCount() const { return _msgcount; } bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; + bool isOnChannelScreen() const { return curr == channel_screen; } void toggleBuzzer(); bool getGPSState(); void toggleGPS(); + // Inject a key press from external source (e.g., keyboard) + void injectKey(char c); + + // Get current screen for checking state + UIScreen* getCurrentScreen() const { return curr; } + UIScreen* getMsgPreviewScreen() const { return msg_preview; } // from AbstractUITask void msgRead(int msgcount) override; @@ -90,4 +99,4 @@ public: void loop() override; void shutdown(bool restart = false); -}; +}; \ No newline at end of file diff --git a/variants/lilygo_tdeck_pro/Tca8418keyboard.h b/variants/lilygo_tdeck_pro/Tca8418keyboard.h new file mode 100644 index 0000000..1728db1 --- /dev/null +++ b/variants/lilygo_tdeck_pro/Tca8418keyboard.h @@ -0,0 +1,254 @@ +#pragma once + +#include +#include + +// TCA8418 Register addresses +#define TCA8418_REG_CFG 0x01 +#define TCA8418_REG_INT_STAT 0x02 +#define TCA8418_REG_KEY_LCK_EC 0x03 +#define TCA8418_REG_KEY_EVENT_A 0x04 +#define TCA8418_REG_KP_GPIO1 0x1D +#define TCA8418_REG_KP_GPIO2 0x1E +#define TCA8418_REG_KP_GPIO3 0x1F +#define TCA8418_REG_DEBOUNCE 0x29 +#define TCA8418_REG_GPI_EM1 0x20 +#define TCA8418_REG_GPI_EM2 0x21 +#define TCA8418_REG_GPI_EM3 0x22 + +// Key codes for special keys +#define KB_KEY_NONE 0 +#define KB_KEY_BACKSPACE '\b' +#define KB_KEY_ENTER '\r' +#define KB_KEY_SPACE ' ' + +class TCA8418Keyboard { +private: + uint8_t _addr; + TwoWire* _wire; + bool _initialized; + bool _shiftActive; // Sticky shift (one-shot) + bool _altActive; // Sticky alt (one-shot) + unsigned long _lastShiftTime; // For Shift+key combos + + uint8_t readReg(uint8_t reg) { + _wire->beginTransmission(_addr); + _wire->write(reg); + _wire->endTransmission(); + _wire->requestFrom(_addr, (uint8_t)1); + return _wire->available() ? _wire->read() : 0; + } + + void writeReg(uint8_t reg, uint8_t val) { + _wire->beginTransmission(_addr); + _wire->write(reg); + _wire->write(val); + _wire->endTransmission(); + } + + // Map raw key codes to characters (from working reader firmware) + char getKeyChar(uint8_t keyCode) { + switch (keyCode) { + // Row 1 - QWERTYUIOP + case 10: return 'q'; // Q (was 97 on different hardware) + case 9: return 'w'; + case 8: return 'e'; + case 7: return 'r'; + case 6: return 't'; + case 5: return 'y'; + case 4: return 'u'; + case 3: return 'i'; + case 2: return 'o'; + case 1: return 'p'; + + // Row 2 - ASDFGHJKL + Backspace + case 20: return 'a'; // A (was 98 on different hardware) + case 19: return 's'; + case 18: return 'd'; + case 17: return 'f'; + case 16: return 'g'; + case 15: return 'h'; + case 14: return 'j'; + case 13: return 'k'; + case 12: return 'l'; + case 11: return '\b'; // Backspace + + // Row 3 - Alt ZXCVBNM Sym Enter + case 30: return 0; // Alt - handled separately + case 29: return 'z'; + case 28: return 'x'; + case 27: return 'c'; + case 26: return 'v'; + case 25: return 'b'; + case 24: return 'n'; + case 23: return 'm'; + case 22: return 0; // Symbol/volume + case 21: return '\r'; // Enter + + // Row 4 - Shift Mic Space Sym Shift + case 35: return 0; // Left shift - handled separately + case 34: return 0; // Mic + case 33: return ' '; // Space + case 32: return 0; // Sym + case 31: return 0; // Right shift - handled separately + + default: return 0; + } + } + + // Map key with Alt modifier for numbers/symbols + char getAltChar(uint8_t keyCode) { + switch (keyCode) { + // Top row with Alt -> numbers + case 10: return '1'; // Q -> 1 + case 9: return '2'; // W -> 2 + case 8: return '3'; // E -> 3 + case 7: return '4'; // R -> 4 + case 6: return '5'; // T -> 5 + case 5: return '6'; // Y -> 6 + case 4: return '7'; // U -> 7 + case 3: return '8'; // I -> 8 + case 2: return '9'; // O -> 9 + case 1: return '0'; // P -> 0 + + // Common symbols with Alt + case 20: return '@'; // A -> @ + case 19: return '#'; // S -> # + case 18: return '$'; // D -> $ + case 17: return '%'; // F -> % + case 16: return '&'; // G -> & + case 15: return '*'; // H -> * + case 14: return '-'; // J -> - + case 13: return '+'; // K -> + + case 12: return '='; // L -> = + + // More symbols + case 29: return '!'; // Z -> ! + case 28: return '?'; // X -> ? + case 27: return ':'; // C -> : + case 26: return ';'; // V -> ; + case 25: return '\''; // B -> ' + case 24: return '"'; // N -> " + case 23: return ','; // M -> , + + default: return 0; + } + } + +public: + TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire) + : _addr(addr), _wire(wire), _initialized(false), + _shiftActive(false), _altActive(false), _lastShiftTime(0) {} + + bool begin() { + // Check if device responds + _wire->beginTransmission(_addr); + if (_wire->endTransmission() != 0) { + Serial.println("TCA8418: Device not found"); + return false; + } + + // Configure keyboard matrix (8 rows x 10 cols) + writeReg(TCA8418_REG_KP_GPIO1, 0xFF); // Rows 0-7 as keypad + writeReg(TCA8418_REG_KP_GPIO2, 0xFF); // Cols 0-7 as keypad + writeReg(TCA8418_REG_KP_GPIO3, 0x03); // Cols 8-9 as keypad + + // Enable keypad with FIFO overflow detection + writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG + + // Set debounce + writeReg(TCA8418_REG_DEBOUNCE, 0x03); + + // Clear any pending interrupts + writeReg(TCA8418_REG_INT_STAT, 0x1F); + + // Flush the FIFO + while (readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) { + readReg(TCA8418_REG_KEY_EVENT_A); + } + + _initialized = true; + Serial.println("TCA8418: Keyboard initialized OK"); + return true; + } + + // Read a key press - returns character or 0 if no key + char readKey() { + if (!_initialized) return 0; + + // Check for key events in FIFO + uint8_t keyCount = readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F; + if (keyCount == 0) return 0; + + // Read key event from FIFO + uint8_t keyEvent = readReg(TCA8418_REG_KEY_EVENT_A); + + // Bit 7: 1 = press, 0 = release + bool pressed = (keyEvent & 0x80) != 0; + uint8_t keyCode = keyEvent & 0x7F; + + // Clear interrupt + writeReg(TCA8418_REG_INT_STAT, 0x1F); + + Serial.printf("KB raw: event=0x%02X code=%d pressed=%d count=%d\n", + keyEvent, keyCode, pressed, keyCount); + + // Only act on key press, not release + if (!pressed || keyCode == 0) { + return 0; + } + + // Handle modifier keys - set sticky state and return 0 + if (keyCode == 35 || keyCode == 31) { // Shift keys + _shiftActive = true; + _lastShiftTime = millis(); + Serial.println("KB: Shift activated"); + return 0; + } + if (keyCode == 30) { // Alt key + _altActive = true; + Serial.println("KB: Alt activated"); + return 0; + } + if (keyCode == 22 || keyCode == 32 || keyCode == 34) { // Sym/Mic - ignore + return 0; + } + + // Get the character + char c = 0; + + if (_altActive) { + c = getAltChar(keyCode); + _altActive = false; // Reset sticky alt + if (c != 0) { + Serial.printf("KB: Alt+key -> '%c'\n", c); + return c; + } + } + + c = getKeyChar(keyCode); + + if (c != 0 && _shiftActive) { + // Apply shift - uppercase letters + if (c >= 'a' && c <= 'z') { + c = c - 'a' + 'A'; + } + _shiftActive = false; // Reset sticky shift + } + + if (c != 0) { + Serial.printf("KB: code %d -> '%c' (0x%02X)\n", keyCode, c >= 32 ? c : '?', c); + } else { + Serial.printf("KB: code %d -> UNMAPPED\n", keyCode); + } + + return c; + } + + bool isReady() const { return _initialized; } + + // Check if shift was pressed within the last N milliseconds + bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const { + return (millis() - _lastShiftTime) < withinMs; + } +}; \ No newline at end of file