From fd33aa8d28c807bfdc4f7ccccc95cb5423e8b855 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:57:46 +1100 Subject: [PATCH] phone touchscreen dialpad now available, initial iteration for alterative to keyboard number text entry; contacts export from Contacts screen to save to sd card --- examples/companion_radio/main.cpp | 226 +++++++++++++++- .../companion_radio/ui-new/Contactsscreen.h | 10 +- examples/companion_radio/ui-new/SMSScreen.h | 242 ++++++++++++++++-- examples/companion_radio/ui-new/Touchinput.h | 128 +++++++++ 4 files changed, 572 insertions(+), 34 deletions(-) create mode 100644 examples/companion_radio/ui-new/Touchinput.h diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 886d14e..fd03ac2 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -77,6 +77,12 @@ static bool smsMode = false; #endif + // Touch input (for phone dialer numpad) + #ifdef HAS_TOUCHSCREEN + #include "TouchInput.h" + TouchInput touchInput(&Wire); + #endif + // Power management #if HAS_GPS GPSDutyCycle gpsDuty; @@ -187,6 +193,135 @@ digitalWrite(SDCARD_CS, HIGH); return restored; } + + // ----------------------------------------------------------------------- + // On-demand export: save current contacts to SD card. + // Writes binary backup + human-readable listing. + // Returns number of contacts exported, or -1 on error. + // ----------------------------------------------------------------------- + int exportContactsToSD() { + if (!sdCardReady) return -1; + + // Ensure in-memory contacts are flushed to SPIFFS first + the_mesh.saveContacts(); + + if (!SD.exists("/meshcore")) SD.mkdir("/meshcore"); + + // 1) Binary backup: SPIFFS /contacts3 → SD /meshcore/contacts.bin + if (!SPIFFS.exists("/contacts3")) return -1; + if (!copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin")) return -1; + + // 2) Human-readable listing for inspection on a computer + int count = 0; + File txt = SD.open("/meshcore/contacts_export.txt", "w", true); + if (txt) { + txt.printf("Meck Contacts Export (%d total)\n", (int)the_mesh.getNumContacts()); + txt.printf("========================================\n"); + txt.printf("%-5s %-30s %s\n", "Type", "Name", "PubKey (prefix)"); + txt.printf("----------------------------------------\n"); + + ContactInfo c; + for (uint32_t i = 0; i < (uint32_t)the_mesh.getNumContacts(); i++) { + if (the_mesh.getContactByIdx(i, c)) { + const char* typeStr = "???"; + switch (c.type) { + case ADV_TYPE_CHAT: typeStr = "Chat"; break; + case ADV_TYPE_REPEATER: typeStr = "Rptr"; break; + case ADV_TYPE_ROOM: typeStr = "Room"; break; + } + // First 8 bytes of pub key as hex identifier + char hexBuf[20]; + mesh::Utils::toHex(hexBuf, c.id.pub_key, 8); + txt.printf("%-5s %-30s %s\n", typeStr, c.name, hexBuf); + count++; + } + } + + txt.printf("========================================\n"); + txt.printf("Total: %d contacts\n", count); + txt.close(); + } + + digitalWrite(SDCARD_CS, HIGH); + Serial.printf("Contacts exported to SD: %d contacts\n", count); + return count; + } + + // ----------------------------------------------------------------------- + // On-demand import: merge contacts from SD backup into live table. + // + // Reads /meshcore/contacts.bin from SD and for each contact: + // - If already in memory (matching pub_key) → skip (keep current) + // - If NOT in memory → addContact (append to table) + // + // This is a non-destructive merge: you never lose contacts already in + // memory, and you gain any that were only in the backup. + // + // After merging, saves the combined set back to SPIFFS so it persists. + // Returns number of NEW contacts added, or -1 on error. + // ----------------------------------------------------------------------- + int importContactsFromSD() { + if (!sdCardReady) return -1; + if (!SD.exists("/meshcore/contacts.bin")) return -1; + + File file = SD.open("/meshcore/contacts.bin", "r"); + if (!file) return -1; + + int added = 0; + int skipped = 0; + + while (true) { + ContactInfo c; + uint8_t pub_key[32]; + uint8_t unused; + + // Parse one contact record (same binary format as DataStore::loadContacts) + bool success = (file.read(pub_key, 32) == 32); + success = success && (file.read((uint8_t *)&c.name, 32) == 32); + success = success && (file.read(&c.type, 1) == 1); + success = success && (file.read(&c.flags, 1) == 1); + success = success && (file.read(&unused, 1) == 1); + success = success && (file.read((uint8_t *)&c.sync_since, 4) == 4); + success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1); + success = success && (file.read((uint8_t *)&c.last_advert_timestamp, 4) == 4); + success = success && (file.read(c.out_path, 64) == 64); + success = success && (file.read((uint8_t *)&c.lastmod, 4) == 4); + success = success && (file.read((uint8_t *)&c.gps_lat, 4) == 4); + success = success && (file.read((uint8_t *)&c.gps_lon, 4) == 4); + + if (!success) break; // EOF or read error + + c.id = mesh::Identity(pub_key); + c.shared_secret_valid = false; + + // Check if this contact already exists in the live table + if (the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE) != NULL) { + skipped++; + continue; // Already have this contact, skip + } + + // New contact — add to the live table + if (the_mesh.addContact(c)) { + added++; + } else { + // Table is full, stop importing + Serial.printf("Import: table full after adding %d contacts\n", added); + break; + } + } + + file.close(); + digitalWrite(SDCARD_CS, HIGH); + + // Persist the merged set to SPIFFS + if (added > 0) { + the_mesh.saveContacts(); + } + + Serial.printf("Contacts import: %d added, %d already present, %d total\n", + added, skipped, (int)the_mesh.getNumContacts()); + return added; + } #endif // Believe it or not, this std C function is busted on some platforms! @@ -548,6 +683,15 @@ void setup() { initKeyboard(); #endif + // Initialize touch input (CST328) + #ifdef HAS_TOUCHSCREEN + if (touchInput.begin(CST328_PIN_INT)) { + MESH_DEBUG_PRINTLN("setup() - Touch input initialized"); + } else { + MESH_DEBUG_PRINTLN("setup() - Touch input FAILED"); + } + #endif + // --------------------------------------------------------------------------- // SD card is already initialized (early init above). // Now set up SD-dependent features: message history + text reader. @@ -861,6 +1005,43 @@ void loop() { #if defined(LilyGo_TDeck_Pro) handleKeyboardInput(); #endif + + // Poll touch input for phone dialer numpad + // Hybrid debounce: finger-up detection + 150ms minimum between accepted taps. + // The CST328 INT pin is pulse-based (not level), so getPoint() can return + // false intermittently during a hold. Time guard prevents that from + // causing repeat fires. + #if defined(HAS_TOUCHSCREEN) && defined(HAS_4G_MODEM) + { + static bool touchFingerDown = false; + static unsigned long lastTouchAccepted = 0; + + if (smsMode) { + SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen(); + if (smsScr && smsScr->getSubView() == SMSScreen::PHONE_DIALER) { + int16_t tx, ty; + if (touchInput.getPoint(tx, ty)) { + unsigned long now = millis(); + if (!touchFingerDown && (now - lastTouchAccepted >= 150)) { + touchFingerDown = true; + lastTouchAccepted = now; + if (smsScr->handleTouch(tx, ty)) { + ui_task.forceRefresh(); + } + } + } else { + // Only allow finger-up after 100ms from last acceptance + // (prevents INT pulse misses from resetting state mid-hold) + if (touchFingerDown && (millis() - lastTouchAccepted >= 100)) { + touchFingerDown = false; + } + } + } else { + touchFingerDown = false; + } + } + } + #endif } // ============================================================================ @@ -1321,13 +1502,20 @@ void handleKeyboardInput() { return; } - // Q from inbox → go home; Q from inner views is handled by SMSScreen + // Q from app menu → go home; Q from inner views is handled by SMSScreen if ((key == 'q' || key == '\b') && smsScr->getSubView() == SMSScreen::APP_MENU) { Serial.println("Nav: SMS -> Home"); ui_task.gotoHomeScreen(); return; } + // Phone dialer: route keys directly (letter keys map to numbers) + if (smsScr->getSubView() == SMSScreen::PHONE_DIALER) { + smsScr->handleInput(key); + ui_task.forceRefresh(); + return; + } + if (smsScr->isComposing()) { // Composing/text input: route directly to screen, bypass injectKey() // to avoid UITask scheduling its own competing refresh @@ -1594,6 +1782,42 @@ void handleKeyboardInput() { } break; + case 'x': + // Export contacts to SD card (contacts screen only) + if (ui_task.isOnContactsScreen()) { + Serial.println("Contacts: Exporting to SD..."); + int exported = exportContactsToSD(); + if (exported >= 0) { + char alertBuf[48]; + snprintf(alertBuf, sizeof(alertBuf), "Exported %d to SD", exported); + ui_task.showAlert(alertBuf, 2000); + } else { + ui_task.showAlert("Export failed (no SD?)", 2000); + } + } + break; + + case 'r': + // Import/merge contacts from SD backup (contacts screen only) + if (ui_task.isOnContactsScreen()) { + Serial.println("Contacts: Importing from SD..."); + int added = importContactsFromSD(); + if (added > 0) { + // Invalidate the contacts screen cache so it rebuilds + ContactsScreen* cs2 = (ContactsScreen*)ui_task.getContactsScreen(); + if (cs2) cs2->invalidateCache(); + char alertBuf[48]; + snprintf(alertBuf, sizeof(alertBuf), "+%d imported (%d total)", + added, (int)the_mesh.getNumContacts()); + ui_task.showAlert(alertBuf, 2500); + } else if (added == 0) { + ui_task.showAlert("No new contacts to add", 2000); + } else { + ui_task.showAlert("Import failed (no backup?)", 2000); + } + } + break; + case 'q': case '\b': // If channel screen path overlay is showing, dismiss it instead of going home diff --git a/examples/companion_radio/ui-new/Contactsscreen.h b/examples/companion_radio/ui-new/Contactsscreen.h index 8cc2c5a..7a4123c 100644 --- a/examples/companion_radio/ui-new/Contactsscreen.h +++ b/examples/companion_radio/ui-new/Contactsscreen.h @@ -88,7 +88,7 @@ private: } } // Sort by last_advert_timestamp descending (most recently seen first) - // Simple insertion sort — fine for up to 400 entries on ESP32 + // Simple insertion sort — fine for up to 400 entries on ESP32 for (int i = 1; i < _filteredCount; i++) { uint16_t tmpIdx = _filteredIdx[i]; uint32_t tmpTs = _filteredTs[i]; @@ -286,17 +286,17 @@ public: display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); - // Left: Q:Back + // Left: Q:Bk X:Exp display.setCursor(0, footerY); - display.print("Q:Back"); + display.print("Q:Bk X:Exp"); // Center: A/D:Filter const char* mid = "A/D:Filtr"; display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY); display.print(mid); - // Right: W/S:Scroll - const char* right = "W/S:Scrll"; + // Right: R:Imp W/S + const char* right = "R:Imp W/S"; display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); display.print(right); diff --git a/examples/companion_radio/ui-new/SMSScreen.h b/examples/companion_radio/ui-new/SMSScreen.h index 9ae43d5..d76d93e 100644 --- a/examples/companion_radio/ui-new/SMSScreen.h +++ b/examples/companion_radio/ui-new/SMSScreen.h @@ -313,8 +313,49 @@ public: return 5000; } - // ---- Phone dialer ---- + // ---- Phone dialer with touch numpad ---- + + // Numpad layout constants — computed dynamically from display dimensions + static const int NUMPAD_ROWS = 5; + static const int NUMPAD_COLS = 3; + + // Button labels: [row][col] + const char* numpadLabel(int row, int col) const { + static const char* labels[5][3] = { + {"1", "2", "3"}, + {"4", "5", "6"}, + {"7", "8", "9"}, + {"*", "0", "#"}, + {"+", "DEL", "CALL"} + }; + return labels[row][col]; + } + + // Button character values: '\b' = backspace, '\r' = call + char numpadChar(int row, int col) const { + static const char chars[5][3] = { + {'1', '2', '3'}, + {'4', '5', '6'}, + {'7', '8', '9'}, + {'*', '0', '#'}, + {'+', '\b', '\r'} + }; + return chars[row][col]; + } + int renderPhoneDialer(DisplayDriver& display) { + int W = display.width(); + int H = display.height(); + + // Layout regions (dynamic based on display size) + int headerH = 12; + int phoneFieldH = 14; + int footerH = 12; + int numpadTop = headerH + phoneFieldH; + int numpadH = H - numpadTop - footerH; + int rowH = numpadH / NUMPAD_ROWS; + int colW = W / NUMPAD_COLS; + // Header display.setTextSize(1); display.setColor(DisplayDriver::GREEN); @@ -322,45 +363,87 @@ public: display.print("Dial Number"); // Signal strength at top-right - renderSignalIndicator(display, display.width() - 2, 0); + renderSignalIndicator(display, W - 2, 0); display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, 11, display.width(), 1); - - // Phone number input - display.setTextSize(0); - display.setColor(DisplayDriver::LIGHT); - display.setCursor(4, 24); - display.print("Enter phone number:"); + display.drawRect(0, headerH - 1, W, 1); + // Phone number field display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); - display.setCursor(4, 40); + display.setCursor(2, headerH + 2); if (_phoneInputPos > 0) { display.print(_phoneInputBuf); } display.print("_"); - // Hint if empty - if (_phoneInputPos == 0) { - display.setTextSize(0); - display.setColor(DisplayDriver::DARK); - display.setCursor(4, 58); - display.print("digits, +, *, #"); - display.setTextSize(1); + // Separator above numpad + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, numpadTop - 1, W, 1); + + // Draw numpad grid + for (int row = 0; row < NUMPAD_ROWS; row++) { + int y = numpadTop + row * rowH; + + // Row separator + if (row > 0) { + display.setColor(DisplayDriver::DARK); + display.drawRect(0, y, W, 1); + } + + for (int col = 0; col < NUMPAD_COLS; col++) { + int x = col * colW; + + // Column separator + if (col > 0) { + display.setColor(DisplayDriver::DARK); + display.drawRect(x, y, 1, rowH); + } + + // Button label - centered in cell + const char* label = numpadLabel(row, col); + bool isAction = (row == 4); // Bottom row has action buttons + + if (isAction) { + display.setTextSize(0); + if (col == 2 && _phoneInputPos > 0) { + display.setColor(DisplayDriver::GREEN); // CALL + } else if (col == 1) { + display.setColor(DisplayDriver::YELLOW); // DEL + } else { + display.setColor(DisplayDriver::LIGHT); + } + } else { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + } + + uint16_t textW = display.getTextWidth(label); + int textH = isAction ? 7 : 8; + int cx = x + (colW - textW) / 2; + int cy = y + (rowH - textH) / 2; + display.setCursor(cx, cy); + display.print(label); + } } // Footer display.setTextSize(1); - int footerY = display.height() - 12; - display.drawRect(0, footerY - 2, display.width(), 1); + int footerY = H - footerH; + display.drawRect(0, footerY - 1, W, 1); display.setColor(DisplayDriver::YELLOW); display.setCursor(0, footerY); - display.print("Q:Back"); + display.print("Q:Bk"); if (_phoneInputPos > 0) { const char* rt = "Ent:Call"; - display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY); + display.setCursor(W - display.getTextWidth(rt) - 2, footerY); display.print(rt); + } else { + // Hint: letter keys type numbers directly + display.setColor(DisplayDriver::DARK); + const char* hint = "W-C=1-9"; + display.setCursor(W - display.getTextWidth(hint) - 2, footerY); + display.print(hint); } return 2000; @@ -820,7 +903,28 @@ public: } } - // ---- Phone dialer input ---- + // ---- Phone dialer input (keyboard) ---- + // + // Three ways to enter digits: + // 1. Touch the on-screen numpad + // 2. Sym+key (normal keyboard number entry) + // 3. Just press the letter key — the dialer maps it automatically + // using the silk-screened number labels on the keyboard: + // w=1 e=2 r=3 | s=4 d=5 f=6 | z=7 x=8 c=9 + // q=# a=* o=+ | 0=mic key (arrives as sym+'0') + + // Map a letter key to its dialer equivalent (0 = no mapping) + // Note: 'q' is reserved for back navigation, use sym+q or touch for '#' + char dialerKeyMap(char c) { + switch (c) { + case 'w': return '1'; case 'e': return '2'; case 'r': return '3'; + case 's': return '4'; case 'd': return '5'; case 'f': return '6'; + case 'z': return '7'; case 'x': return '8'; case 'c': return '9'; + case 'a': return '*'; case 'o': return '+'; + default: return 0; + } + } + bool handlePhoneDialerInput(char c) { switch (c) { case '\r': // Enter - place call @@ -838,23 +942,105 @@ public: } return true; - case 'q': case 'Q': // Back to app menu + case 'q': // Back to app menu _phoneInputBuf[0] = '\0'; _phoneInputPos = 0; _view = APP_MENU; return true; default: - // Accept phone number characters - if (_phoneInputPos < SMS_PHONE_LEN - 1 && - ((c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#')) { - _phoneInputBuf[_phoneInputPos++] = c; - _phoneInputBuf[_phoneInputPos] = '\0'; + // Accept phone number characters directly (from sym+key) + if ((c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#') { + if (_phoneInputPos < SMS_PHONE_LEN - 1) { + _phoneInputBuf[_phoneInputPos++] = c; + _phoneInputBuf[_phoneInputPos] = '\0'; + } + return true; + } + // Map plain letter keys to their silk-screened number equivalents + char mapped = dialerKeyMap(c); + if (mapped) { + if (_phoneInputPos < SMS_PHONE_LEN - 1) { + _phoneInputBuf[_phoneInputPos++] = mapped; + _phoneInputBuf[_phoneInputPos] = '\0'; + } + return true; } return true; } } + // ---- Touch numpad input (called from main loop when touch detected) ---- + + // Process a touch event at PHYSICAL display coordinates (px, py). + // The touch controller reports 240x320; display draws in virtual coords. + // Returns true if the touch was consumed (a button was pressed). + // Caller should call forceRefresh() after this returns true. + // + // dispW/dispH: virtual display dimensions (display.width()/height()) + // physW/physH: physical touch panel dimensions (240/320) + bool handleTouch(int16_t px, int16_t py, int dispW = 128, int dispH = 128, + int physW = 240, int physH = 320) { + if (_view != PHONE_DIALER) return false; + + // Map physical touch coordinates to virtual display coordinates + int x = (int)px * dispW / physW; + int y = (int)py * dispH / physH; + + // Compute layout (must match renderPhoneDialer) + int headerH = 12; + int phoneFieldH = 14; + int footerH = 12; + int numpadTop = headerH + phoneFieldH; + int numpadH = dispH - numpadTop - footerH; + int rowH = numpadH / NUMPAD_ROWS; + int colW = dispW / NUMPAD_COLS; + + // Check bounds: must be within numpad grid + if (y < numpadTop || y >= numpadTop + NUMPAD_ROWS * rowH) return false; + if (x < 0 || x >= NUMPAD_COLS * colW) return false; + + // Map coordinates to grid cell + int col = x / colW; + int row = (y - numpadTop) / rowH; + + if (col < 0 || col >= NUMPAD_COLS || row < 0 || row >= NUMPAD_ROWS) return false; + + char c = numpadChar(row, col); + bool changed = false; + + if (c == '\r') { + // CALL button + if (_phoneInputPos > 0) { + _phoneInputBuf[_phoneInputPos] = '\0'; + modemManager.dialCall(_phoneInputBuf); + changed = true; + } + } else if (c == '\b') { + // DEL button + if (_phoneInputPos > 0) { + _phoneInputPos--; + _phoneInputBuf[_phoneInputPos] = '\0'; + changed = true; + } + } else { + // Digit/symbol button + if (_phoneInputPos < SMS_PHONE_LEN - 1) { + _phoneInputBuf[_phoneInputPos++] = c; + _phoneInputBuf[_phoneInputPos] = '\0'; + changed = true; + } + } + + Serial.printf("[Touch] Numpad: phys=(%d,%d) virt=(%d,%d) row=%d col=%d btn=%s %s\n", + px, py, x, y, row, col, numpadLabel(row, col), + changed ? "OK" : "NOOP"); + return changed; + } + + // clearTouch() is a no-op with time-based debounce, kept for API compat + void clearTouch() { } + // ---- Inbox input ---- bool handleInboxInput(char c) { switch (c) { diff --git a/examples/companion_radio/ui-new/Touchinput.h b/examples/companion_radio/ui-new/Touchinput.h new file mode 100644 index 0000000..837199c --- /dev/null +++ b/examples/companion_radio/ui-new/Touchinput.h @@ -0,0 +1,128 @@ +#pragma once + +// ============================================================================= +// TouchInput - Minimal CST328/CST3530 touch driver for T-Deck Pro +// +// Uses raw I2C reads on the shared Wire bus. No external library needed. +// Protocol confirmed via raw serial capture from actual hardware: +// +// Register 0xD000, 7 bytes: +// buf[0]: event flags (0xAB = idle/no touch, other = active touch) +// buf[1]: X coordinate high data +// buf[2]: Y coordinate high data +// buf[3]: X low nibble (bits 7:4) | Y low nibble (bits 3:0) +// buf[4]: pressure +// buf[5]: touch count (& 0x7F), typically 0x01 for single touch +// buf[6]: 0xAB always (check byte, ignore) +// +// Coordinate formula: +// x = (buf[1] << 4) | ((buf[3] >> 4) & 0x0F) → 0..239 +// y = (buf[2] << 4) | (buf[3] & 0x0F) → 0..319 +// +// Hardware: CST328 at 0x1A, INT=GPIO12, RST=GPIO38 (V1.1) +// +// Guard: HAS_TOUCHSCREEN +// ============================================================================= + +#ifdef HAS_TOUCHSCREEN + +#ifndef TOUCH_INPUT_H +#define TOUCH_INPUT_H + +#include +#include + +class TouchInput { +public: + static const uint8_t TOUCH_ADDR = 0x1A; + + TouchInput(TwoWire* wire = &Wire) + : _wire(wire), _intPin(-1), _initialized(false), _debugCount(0), _lastPoll(0) {} + + bool begin(int intPin) { + _intPin = intPin; + pinMode(_intPin, INPUT); + + // Verify the touch controller is present on the bus + _wire->beginTransmission(TOUCH_ADDR); + uint8_t err = _wire->endTransmission(); + if (err != 0) { + Serial.printf("[Touch] CST328 not found at 0x%02X (err=%d)\n", TOUCH_ADDR, err); + return false; + } + + Serial.printf("[Touch] CST328 found at 0x%02X, INT=GPIO%d\n", TOUCH_ADDR, _intPin); + _initialized = true; + return true; + } + + bool isReady() const { return _initialized; } + + // Poll for touch. Returns true if a finger is down, fills x and y. + // Coordinates are in physical display space (0-239 X, 0-319 Y). + // NOTE: CST328 INT pin is pulse-based, not level. We cannot rely on + // digitalRead(INT) for touch state. Instead, always read and check buf[0]. + bool getPoint(int16_t &x, int16_t &y) { + if (!_initialized) return false; + + // Rate limit: poll at most every 20ms (50 Hz) to avoid I2C bus congestion + unsigned long now = millis(); + if (now - _lastPoll < 20) return false; + _lastPoll = now; + + uint8_t buf[7]; + memset(buf, 0, sizeof(buf)); + + // Write register address 0xD000 + _wire->beginTransmission(TOUCH_ADDR); + _wire->write(0xD0); + _wire->write(0x00); + if (_wire->endTransmission(false) != 0) return false; + + // Read 7 bytes of touch data + uint8_t received = _wire->requestFrom(TOUCH_ADDR, (uint8_t)7); + if (received < 7) return false; + for (int i = 0; i < 7; i++) buf[i] = _wire->read(); + + // buf[0] == 0xAB means idle (no touch active) + if (buf[0] == 0xAB) return false; + + // buf[0] == 0x00 can appear on finger-up transition — ignore + if (buf[0] == 0x00) return false; + + // Touch count from buf[5] + uint8_t count = buf[5] & 0x7F; + if (count == 0 || count > 5) return false; + + // Parse coordinates (CST226/CST328 format confirmed by hardware capture) + // x = (buf[1] << 4) | high nibble of buf[3] + // y = (buf[2] << 4) | low nibble of buf[3] + int16_t tx = ((int16_t)buf[1] << 4) | ((buf[3] >> 4) & 0x0F); + int16_t ty = ((int16_t)buf[2] << 4) | (buf[3] & 0x0F); + + // Sanity check (panel is 240x320) + if (tx < 0 || tx > 260 || ty < 0 || ty > 340) return false; + + // Debug: log first 20 touch events with parsed coordinates + if (_debugCount < 50) { + Serial.printf("[Touch] Raw: %02X %02X %02X %02X %02X %02X %02X → x=%d y=%d\n", + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], + tx, ty); + _debugCount++; + } + + x = tx; + y = ty; + return true; + } + +private: + TwoWire* _wire; + int _intPin; + bool _initialized; + int _debugCount; + unsigned long _lastPoll; +}; + +#endif // TOUCH_INPUT_H +#endif // HAS_TOUCHSCREEN \ No newline at end of file