// SPDX-License-Identifier: GPL-3.0-or-later // LilyGo T-Display P4 (AMOLED) touch input — implements the shared touch-input // API (HeltecV4CapTouch.h) for the board's HI8561 capacitive controller. // // The HI8561 shares I2C_1 (Wire, SDA=7 / SCL=8) with the XL9535 expander, the // PCF8563 RTC and the BQ27220 gauge. Wire is already begun by the XL9535 driver // (xl9535.begin(7,8) in main.cpp) and the controller's RST/INT lines are driven // by the expander — powerOnSequence() releases TOUCH_RST (IO3) before we probe. // So, like RakTapV2Touch, this driver uses the shared Wire and must NOT re-init // or reset the bus. // // STATE MACHINE: the tap/swipe/long-press gesture logic below is the proven one // shared with the CHSC6x (Heltec V4) and FT5x06 (RAK Tap) drivers — only the // physical report read (hi8561ReadPoint) and the panel geometry differ. // // ⚠️ FIRST-BOOT STATUS (display-first): hi8561ReadPoint() currently reports NO // touch — the panel boots and renders, but taps aren't delivered yet. Enabling // real touch is a single localised change: fill in the HI8561 report parse (addr // 0x48; see the TODO in hi8561ReadPoint) and verify the coordinate mapping on the // device. A wrong report layout produces phantom presses that lock the UI, so it // stays disabled until it can be validated against hardware. Everything else // (probe, async poll task, gesture machine, rotation) is complete. #if defined(HAS_HI8561_TOUCH) && defined(ESP32) #include // shared touch-API contract (this file lives in variants/, not next to it) #include #include #include #include #include #ifndef PIN_TOUCH_SDA #define PIN_TOUCH_SDA 7 #endif #ifndef PIN_TOUCH_SCL #define PIN_TOUCH_SCL 8 #endif // RM69A10 AMOLED is a fixed 568×1232 portrait panel (no display rotation on this // board — CAP_ROTATABLE=0). The controller reports in the same portrait space, // so raw == screen and the default (identity) mapping applies. // LVGL renders at 284x616 (half the 568x1232 panel; RM69A10Display upscales 2x on flush), so touch // coordinates must be reported in that SAME 284x616 logical space — the GT9895's native 1060x2400 // grid is scaled straight to 284x616 here. // Logical (LVGL) space = native panel / UI-scale(2). The LilyGo T-Display P4 has TWO panel SKUs, // each with its OWN touch controller (a build-time choice — HAS_TDP4_LCD): // AMOLED (RM69A10, 568x1232) -> 284x616 logical, touch = Goodix GT9895 (0x5D). // TFT-LCD (HI8561, 540x1168) -> 270x584 logical, touch = HI8561 integrated TDDI touch (0x68). #if defined(HAS_TDP4_LCD) static const int SCR_W = 270; static const int SCR_H = 584; static const int RAW_W = 270; static const int RAW_H = 584; // The HI8561 is a TDDI (touch+display in one). Its integrated touch is at I2C 0x68 and reads via an // indirect ERAM-pointer protocol (ported from LilyGo cpp_bus_driver hi8561_touch.cpp, GPL-3.0): // once at init, read a touch-info start-address out of the ERAM section table (InitAddressInfo); // then each poll writes {0xF3, addr(BE32), 0x03} and reads the point back (x/y big-endian, 0xFFFF = // no finger). It reports in panel-native 540x1168; we scale /2 (SCR_W/HI8561_NATIVE_W) to logical. // TODO(device): confirm the native grid + axis orientation against on-screen taps (the touch-debug // overlay shows raw coords, via heltecV4CapTouchGetRaw). static const uint8_t HI8561_ADDR = 0x68; static const uint32_t HI8561_ERAM_BASE = 0x20011000; static const uint16_t HI8561_ERAM_SIZE = 4096; // kEsramSectionInfoStartAddress = base + 4 + 25*8 + 4 = 0x200110D0 (see cpp_bus_driver hi8561_touch.h). static const uint32_t HI8561_ESRAM_SECTION_INFO = HI8561_ERAM_BASE + 4 + 25 * 8 + 4; static const uint8_t HI8561_POINT_OFFSET = 3; // kTouchPointAddressOffset static const int HI8561_NATIVE_W = 540; static const int HI8561_NATIVE_H = 1168; static uint32_t s_hi8561_info_addr = 0; // touch_info_start_address_ (learned at init) #else static const int SCR_W = 284; static const int SCR_H = 616; static const int RAW_W = 284; static const int RAW_H = 616; // NOTE: despite this file's name, the LilyGo T-Display P4 AMOLED (RM69A10) SKU actually ships a // Goodix GT9895 touch controller at 0x5D — NOT the HI8561 (that's the LCD SKU, gated above). An I2C // bus scan on the real board found 0x5D. This path talks to the GT9895. Ported from LilyGo // cpp_bus_driver gt9895.cpp: the touch report is at register 0x00010308; each read = write that // 32-bit address big-endian (4 bytes), then read the report. static const uint8_t GT9895_ADDR = 0x5D; static const uint8_t GT9895_TOUCH_CMD[4] = { 0x00, 0x01, 0x03, 0x08 }; // register 0x00010308, big-endian // The GT9895 reports in its native 1060x2400 digitiser grid; scale to the 284x616 logical space. static const int GT9895_NATIVE_W = 1060; static const int GT9895_NATIVE_H = 2400; #endif static bool s_init_ok = false; static bool s_given_up = false; static int s_retries = 0; static char s_scan_str[128] = "scan: (not run)"; static volatile uint16_t s_dbg_rawx = 0, s_dbg_rawy = 0; static bool s_have_touch = false; static uint16_t s_cur_x = 0, s_cur_y = 0; static bool s_down = false; static unsigned long s_down_at = 0; static uint16_t s_start_x = 0, s_start_y = 0; static uint16_t s_last_x = 0, s_last_y = 0; static bool s_live = false; static uint16_t s_live_x = 0, s_live_y = 0; static bool s_tap_pending = false; static uint16_t s_tap_x = 0, s_tap_y = 0; static bool s_swiping_now = false; static bool s_swipe_pending = false; static int8_t s_swipe_x = 0, s_swipe_y = 0; static uint8_t s_ui_rotation = 0; static uint8_t s_point_rotation = 0; // Larger panel -> larger swipe threshold than the 240×320 boards. #ifndef P4_TOUCH_SWIPE_MIN #define P4_TOUCH_SWIPE_MIN 60 #endif #ifndef P4_TOUCH_TAP_MOVE_MAX #define P4_TOUCH_TAP_MOVE_MAX 24 #endif #ifndef P4_TOUCH_LONG_MS #define P4_TOUCH_LONG_MS 1000 #endif // ---- Touch-controller low-level access ---- // Write an N-byte command (repeated start, no stop), then read M bytes. One helper per SKU (only // the compiled one is used); the transaction shape is identical, just the I2C address differs. #if defined(HAS_TDP4_LCD) static bool hi8561WriteRead(const uint8_t* cmd, uint8_t clen, uint8_t* out, uint8_t olen) { Wire.beginTransmission(HI8561_ADDR); Wire.write(cmd, clen); if (Wire.endTransmission(false) != 0) return false; // repeated start, no stop uint8_t got = Wire.requestFrom((int)HI8561_ADDR, (int)olen); if (got != olen) return false; for (uint8_t i = 0; i < olen; i++) out[i] = Wire.read(); return true; } #else static bool gt9895WriteRead(const uint8_t* cmd, uint8_t clen, uint8_t* out, uint8_t olen) { Wire.beginTransmission(GT9895_ADDR); Wire.write(cmd, clen); if (Wire.endTransmission(false) != 0) return false; // repeated start, no stop uint8_t got = Wire.requestFrom((int)GT9895_ADDR, (int)olen); if (got != olen) return false; for (uint8_t i = 0; i < olen; i++) out[i] = Wire.read(); return true; } #endif // ---- Init (called by UITask loop every tick until success or give-up) ---- bool heltecV4CapTouchBegin() { if (s_init_ok) return true; if (s_given_up) return false; ++s_retries; if (s_retries > 3) { s_given_up = true; Serial.println("[TOUCH] HI8561 giving up after 3 attempts — booting without touch"); return false; } // Wire is up on 7/8 (xl9535.begin). Bump to 400 kHz + short timeout so a stuck // controller can't stall the UI loop. Wire.setClock(400000); Wire.setTimeOut(20); delay(10); // Bring-up: scan the I2C bus once so we can see exactly what touch controller is present // (HI8561=0x68, GT9895=0x5D/0x14, others) — the RM69A10 variant may pair with GT9895, not HI8561. if (s_retries == 1) { char sc[96]; int n = 0; n += snprintf(sc, sizeof sc, "[TOUCH] i2c7/8 scan:"); for (uint8_t a = 0x08; a < 0x78 && n < (int)sizeof sc - 6; a++) { Wire.beginTransmission(a); if (Wire.endTransmission() == 0) n += snprintf(sc + n, sizeof sc - n, " %02X", a); } Serial.println(sc); } #if defined(HAS_TDP4_LCD) // HI8561 integrated TDDI touch at 0x68 — probe, then learn the ERAM touch-info pointer once. Wire.beginTransmission(HI8561_ADDR); if (Wire.endTransmission() != 0) { snprintf(s_scan_str, sizeof s_scan_str, "i2c7/8: no ACK at 0x%02X", HI8561_ADDR); Serial.printf("[TOUCH] HI8561 not found (retry %d/3)\n", s_retries); return false; } // InitAddressInfo: read touch_info_start_address_ out of the ERAM section table (offset 8). { const uint32_t sa = HI8561_ESRAM_SECTION_INFO; uint8_t cmd[6] = { 0xF3, (uint8_t)(sa >> 24), (uint8_t)(sa >> 16), (uint8_t)(sa >> 8), (uint8_t)sa, 0x03 }; uint8_t buf[48] = {0}; if (!hi8561WriteRead(cmd, sizeof cmd, buf, sizeof buf)) { snprintf(s_scan_str, sizeof s_scan_str, "0x%02X hi8561 info read fail", HI8561_ADDR); Serial.printf("[TOUCH] %s (retry %d/3)\n", s_scan_str, s_retries); return false; } uint32_t info = (uint32_t)buf[8] | ((uint32_t)buf[9] << 8) | ((uint32_t)buf[10] << 16) | ((uint32_t)buf[11] << 24); if (info < HI8561_ERAM_BASE || info >= (HI8561_ERAM_BASE + HI8561_ERAM_SIZE)) { snprintf(s_scan_str, sizeof s_scan_str, "0x%02X hi8561 info bad 0x%08lX", HI8561_ADDR, (unsigned long)info); Serial.printf("[TOUCH] %s (retry %d/3)\n", s_scan_str, s_retries); return false; } s_hi8561_info_addr = info; } s_init_ok = true; snprintf(s_scan_str, sizeof s_scan_str, "0x%02X hi8561 OK (info 0x%08lX)", HI8561_ADDR, (unsigned long)s_hi8561_info_addr); Serial.printf("[TOUCH] %s\n", s_scan_str); return true; #else Wire.beginTransmission(GT9895_ADDR); if (Wire.endTransmission() != 0) { snprintf(s_scan_str, sizeof s_scan_str, "i2c7/8: no ACK at 0x%02X", GT9895_ADDR); Serial.printf("[TOUCH] GT9895 not found (retry %d/3)\n", s_retries); return false; } s_init_ok = true; snprintf(s_scan_str, sizeof s_scan_str, "0x%02X gt9895 OK (retry %d)", GT9895_ADDR, s_retries - 1); Serial.printf("[TOUCH] %s\n", s_scan_str); return true; #endif } // ---- Coordinate mapping (identity in portrait; rotation kept for parity) ---- static void mapRaw(uint16_t rx, uint16_t ry, uint16_t* ox, uint16_t* oy) { int sx, sy; switch (s_point_rotation) { case 1: sx = (RAW_H - 1) - (int)ry; sy = (int)rx; break; // ROT_90 case 3: sx = (int)ry; sy = (RAW_W - 1) - (int)rx; break; // ROT_270 default: sx = (int)rx; sy = (int)ry; break; // portrait / 180 } if (sx < 0) sx = 0; if (sx >= SCR_W) sx = SCR_W - 1; if (sy < 0) sy = 0; if (sy >= SCR_H) sy = SCR_H - 1; *ox = (uint16_t)sx; *oy = (uint16_t)sy; } // ---- Physical report read ------------------------------------------------ // Returns 1 = finger down (rx/ry set to logical coords), 0 = no touch, -1 = I2C error. // TODO(device): verify X/Y axis orientation + origin against on-screen taps (may need flip/swap in mapRaw). #if defined(HAS_TDP4_LCD) // HI8561 integrated touch: read finger 1's point at (info_addr + kTouchPointAddressOffset). The // report is 5 bytes: [x_hi][x_lo][y_hi][y_lo][pressure] (x/y big-endian); 0xFFFF/0xFFFF = no touch. static int hi8561ReadPoint(uint16_t* rx, uint16_t* ry) { if (!s_hi8561_info_addr) return -1; const uint32_t addr = s_hi8561_info_addr + HI8561_POINT_OFFSET; // finger 1 (stride handled at init) uint8_t cmd[6] = { 0xF3, (uint8_t)(addr >> 24), (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr, 0x03 }; uint8_t buf[5] = {0}; if (!hi8561WriteRead(cmd, sizeof cmd, buf, sizeof buf)) return -1; uint16_t x = ((uint16_t)buf[0] << 8) | buf[1]; // big-endian uint16_t y = ((uint16_t)buf[2] << 8) | buf[3]; if (x == 0xFFFF && y == 0xFFFF) return 0; // no finger down if (x >= HI8561_NATIVE_W) x = HI8561_NATIVE_W - 1; // clamp a stray out-of-range report if (y >= HI8561_NATIVE_H) y = HI8561_NATIVE_H - 1; // Scale the native 540x1168 grid down to the 270x584 logical space. *rx = (uint16_t)((uint32_t)x * SCR_W / HI8561_NATIVE_W); *ry = (uint16_t)((uint32_t)y * SCR_H / HI8561_NATIVE_H); return 1; } #else // GT9895 single-touch read: 16 bytes = TOUCH_POINT_ADDRESS_OFFSET(8) + 1*SINGLE_TOUCH_POINT_DATA_SIZE(8). // buf[2] = finger count; point data at offset 8: [id/status][?][x_lo][x_hi][y_lo][y_hi][pressure][?] // (x/y little-endian). static int hi8561ReadPoint(uint16_t* rx, uint16_t* ry) { uint8_t buf[16] = {0}; if (!gt9895WriteRead(GT9895_TOUCH_CMD, 4, buf, sizeof buf)) return -1; uint8_t fingers = buf[2]; if (fingers < 1 || fingers > 10) return 0; // no finger down uint32_t x = (uint32_t)buf[10] | ((uint32_t)buf[11] << 8); uint32_t y = (uint32_t)buf[12] | ((uint32_t)buf[13] << 8); // Scale the native 1060x2400 grid down to the 284x616 logical space. *rx = (uint16_t)(x * (uint32_t)SCR_W / GT9895_NATIVE_W); *ry = (uint16_t)(y * (uint32_t)SCR_H / GT9895_NATIVE_H); return 1; } #endif static void hi8561Poll() { uint16_t rx = 0, ry = 0; if (hi8561ReadPoint(&rx, &ry) == 1) { s_dbg_rawx = rx; s_dbg_rawy = ry; mapRaw(rx, ry, &s_cur_x, &s_cur_y); s_have_touch = true; } else { s_have_touch = false; } } void heltecV4CapTouchGetRaw(uint16_t* rx, uint16_t* ry) { if (rx) *rx = s_dbg_rawx; if (ry) *ry = s_dbg_rawy; } // ---- Gesture state machine (shared logic; mirrors RakTapV2Touch) ---- int heltecV4CapTouchCheck() { if (!s_init_ok) return BUTTON_EVENT_NONE; hi8561Poll(); if (s_have_touch) { s_live = true; s_live_x = s_cur_x; s_live_y = s_cur_y; if (!s_down) { s_down = true; s_down_at = millis(); s_start_x = s_cur_x; s_start_y = s_cur_y; s_swiping_now = false; } s_last_x = s_cur_x; s_last_y = s_cur_y; if (!s_swiping_now) { int adx = abs((int)s_last_x - (int)s_start_x); int ady = abs((int)s_last_y - (int)s_start_y); if (adx >= P4_TOUCH_SWIPE_MIN && adx > ady) s_swiping_now = true; } return BUTTON_EVENT_NONE; } if (s_down) { s_down = false; s_live = false; s_swiping_now = false; unsigned long dur = millis() - s_down_at; int adx = abs((int)s_last_x - (int)s_start_x); int ady = abs((int)s_last_y - (int)s_start_y); if (adx >= P4_TOUCH_SWIPE_MIN && adx > (ady + 8)) { bool left = ((int)s_last_x - (int)s_start_x) < 0; s_swipe_x = left ? -1 : 1; s_swipe_y = 0; s_swipe_pending = true; return left ? BUTTON_EVENT_DOUBLE_CLICK : BUTTON_EVENT_TRIPLE_CLICK; } if (ady >= P4_TOUCH_SWIPE_MIN && ady > (adx + 8)) { s_swipe_x = 0; s_swipe_y = ((int)s_last_y - (int)s_start_y) < 0 ? -1 : 1; s_swipe_pending = true; return BUTTON_EVENT_NONE; } if (dur >= 12 && dur < (unsigned long)P4_TOUCH_LONG_MS && adx <= P4_TOUCH_TAP_MOVE_MAX && ady <= P4_TOUCH_TAP_MOVE_MAX) { s_tap_x = s_last_x; s_tap_y = s_last_y; s_tap_pending = true; return BUTTON_EVENT_CLICK; } if (dur >= (unsigned long)P4_TOUCH_LONG_MS) { return BUTTON_EVENT_LONG_PRESS; } } else { s_live = false; } return BUTTON_EVENT_NONE; } bool heltecV4CapTouchGetLive(uint16_t* x, uint16_t* y) { if (!s_live) return false; if (x) *x = s_live_x; if (y) *y = s_live_y; return true; } bool heltecV4CapTouchPopTap(uint16_t* x, uint16_t* y) { if (!s_tap_pending) return false; s_tap_pending = false; if (x) *x = s_tap_x; if (y) *y = s_tap_y; return true; } bool heltecV4CapTouchPopSwipe(int8_t* xd, int8_t* yd) { if (!s_swipe_pending) return false; s_swipe_pending = false; if (xd) *xd = s_swipe_x; if (yd) *yd = s_swipe_y; return true; } // ---- Background poll (core 0) ---- static TaskHandle_t s_poll_task = nullptr; static volatile bool s_async = false; static uint32_t s_period_ms = 8; static void touchPollTask(void* arg) { (void)arg; for (;;) { heltecV4CapTouchCheck(); vTaskDelay(pdMS_TO_TICKS(s_period_ms)); } } bool heltecV4CapTouchStartBackgroundPoll(uint32_t period_ms) { if (s_async || !s_init_ok) return false; s_period_ms = period_ms < 4 ? 4 : (period_ms > 100 ? 100 : period_ms); BaseType_t ok = xTaskCreatePinnedToCore(touchPollTask, "hi8561_touch", 3072, nullptr, 2, &s_poll_task, 0); if (ok == pdPASS) { s_async = true; return true; } return false; } bool heltecV4CapTouchIsAsyncPolling() { return s_async; } bool heltecV4CapTouchIsSwiping() { return s_swiping_now; } void heltecV4CapTouchSetRotation(uint8_t r) { s_ui_rotation = r & 3; } void heltecV4CapTouchSetPointRotation(uint8_t r) { s_point_rotation = r & 3; } const char* heltecV4CapTouchDebug() { return s_scan_str; } #endif // HAS_HI8561_TOUCH && ESP32