From ba889e55d9aa0ac90428c5d18775d13d2971dd97 Mon Sep 17 00:00:00 2001 From: Erica Jensen Date: Fri, 14 Aug 2026 20:42:46 -0700 Subject: [PATCH 1/2] Add opt-in GT911 INT wake support (FREEINK_GT911_INT_WAKE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GT911 driver never touched the INT line after the reset/address-select dance: beginGt911() left it as a plain INPUT and every contact was found by polling 0x814E over I2C. What the line does between frames is decided by the controller's own self-loaded config (this panel has no host-uploaded config table), whose Module_Switch1 (0x804D) bits[1:0] select the INT trigger: 0 rising edge, 1 falling edge, 2 low level, 3 high level. Only the two level modes hold the line from the report frame until the host clears 0x814E; the edge modes emit one pulse per frame, and light sleep clock-gates the SoC's edge detector, so a host can wake on a level and never on a pulse. Under FREEINK_GT911_INT_WAKE (default off), beginGt911() now reads the config block (0x8047..0x80FE plus its checksum at 0x80FF), verifies the stored 8-bit two's-complement checksum, and — only if the INT trigger is not already low level — patches Module_Switch1, recomputes the checksum and applies it with Config_Fresh. Config_Version is written as 0x00 so the table is applied but not burned into the controller's NVM: the factory config returns on the next reset and the write can never permanently alter the hardware, at the cost of re-applying it on every boot. The result is verified by reading 0x804D back, and the INT pin is pulled up so an open-drain module still idles HIGH. touchWakeIrqPin() reports the INT GPIO only when that verification passed, so a caller can never arm a level wake on a line that merely pulses; it returns -1 without the flag, without touch, or when the controller refuses the write. Flag off = no config read, no write, no pin-mode change, accessor always -1. Residual risks: a panel that rejects a version-0x00 config silently keeps its factory INT mode (reported as unusable, TOUCH_PROBE_DEBUG logs it) — making it stick would need a persisted write, which is deliberately not done here; and pollGt911()'s early return on an I2C read failure leaves 0x814E uncleared, so a bus fault would hold INT asserted and suppress sleep until it recovers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UUgQffv7GGVCWQ9gijsVbp --- .../InputManager/include/InputManager.h | 19 +++ .../InputManager/src/InputManager.cpp | 110 ++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/libs/hardware/InputManager/include/InputManager.h b/libs/hardware/InputManager/include/InputManager.h index 3df38433..63d2d9b8 100644 --- a/libs/hardware/InputManager/include/InputManager.h +++ b/libs/hardware/InputManager/include/InputManager.h @@ -141,6 +141,18 @@ class InputManager { // Cleared each #update(). bool wasHomeKeyLongPressed() const; + // Touch INT line usable as a light-sleep wake source, or -1. A host can only + // wake from light sleep on a GPIO LEVEL (the edge detector is clock-gated + // while asleep), so this reports the pin ONLY once the driver has put the + // controller in a level-hold INT mode — the line then stays asserted from the + // report frame until the poll clears the status register, which a level wake + // cannot miss. -1 whenever that cannot be guaranteed (no touch, probe failed, + // FREEINK_GT911_INT_WAKE not built, or the controller refused the mode); + // callers must keep polling in that case. + int8_t touchWakeIrqPin() const; + // Asserted level of #touchWakeIrqPin (true = LOW). + bool touchWakeIrqActiveLow() const; + // Optional board hook for buttons that aren't direct GPIOs — e.g. a key // behind an I2C IO-expander (the LilyGo T5 S3 user button on its PCA9535). It // returns a (1<(-1); +#else + return -1; +#endif +} + +// Only the GT911 low-level mode is programmed today, so the answer is constant. +bool InputManager::touchWakeIrqActiveLow() const { return true; } + InputManager::TouchPoint InputManager::getTouchPoint() const { return touchPoint; } bool InputManager::isTouchPressed() const { return touchPressed; } bool InputManager::wasTouchPressed() const { return touchPressedEvent; } @@ -1090,6 +1101,11 @@ void InputManager::beginGt911() { } touchDataEnabled = (gt911Addr != 0); +#ifdef FREEINK_GT911_INT_WAKE + if (touchDataEnabled) { + gt911ConfigureIntWake(); + } +#endif #ifdef TOUCH_PROBE_DEBUG touchDebugPrintf( "[touch] GT911 probe: addr=0x%02X enabled=%d (sda=%d scl=%d " @@ -1367,6 +1383,100 @@ void InputManager::pollFt6336u(const unsigned long now) { } } +#ifdef FREEINK_GT911_INT_WAKE + +// GT911 configuration block: 0x8047..0x80FE, its 8-bit two's-complement +// checksum at 0x80FF, the apply flag at 0x8100. Module_Switch1 (0x804D) +// bits[1:0] pick the INT trigger — 0 rising edge, 1 falling edge, 2 low level, +// 3 high level. Only the level modes hold the line from the report frame until +// the host clears 0x814E; the edge modes emit one pulse per frame, and light +// sleep clock-gates the edge detector, so a pulse can be missed entirely. +namespace { +constexpr uint16_t GT911_CONFIG_START = 0x8047; +constexpr uint16_t GT911_CONFIG_LEN = 184; // 0x8047..0x80FE, checksum excluded +constexpr uint8_t GT911_CONFIG_CHUNK = 64; // Wire's buffer is 128 bytes +constexpr uint16_t GT911_MODULE_SWITCH1_OFF = 0x804D - GT911_CONFIG_START; +constexpr uint8_t GT911_INT_MODE_MASK = 0x03; +constexpr uint8_t GT911_INT_LOW_LEVEL = 0x02; +} // namespace + +bool InputManager::gt911WriteReg(const uint16_t reg, const uint8_t* buf, const uint8_t len) { + Wire.beginTransmission(gt911Addr); + Wire.write(static_cast(reg >> 8)); + Wire.write(static_cast(reg & 0xFF)); + for (uint8_t i = 0; i < len; ++i) { + Wire.write(buf[i]); + } + return Wire.endTransmission() == 0; +} + +void InputManager::gt911ConfigureIntWake() { + const auto& t = BoardConfig::ACTIVE.touch; + if (gt911Addr == 0 || t.irq < 0) { + return; + } + + uint8_t cfg[GT911_CONFIG_LEN + 1] = {}; // config block + its stored checksum + for (uint16_t off = 0; off < sizeof(cfg); off += GT911_CONFIG_CHUNK) { + const uint16_t remaining = static_cast(sizeof(cfg) - off); + const uint8_t len = static_cast(remaining < GT911_CONFIG_CHUNK ? remaining : GT911_CONFIG_CHUNK); + if (!gt911ReadReg(static_cast(GT911_CONFIG_START + off), cfg + off, len)) { + return; + } + } + + uint8_t sum = 0; + for (uint16_t i = 0; i < GT911_CONFIG_LEN; ++i) { + sum = static_cast(sum + cfg[i]); + } + if (static_cast(sum + cfg[GT911_CONFIG_LEN]) != 0) { + // Checksum mismatch: the table is not what this driver thinks it is, so + // rewriting any of it would be a guess. Leave the controller alone. +#ifdef TOUCH_PROBE_DEBUG + touchDebugPrintf("[touch] GT911 int-wake: config checksum mismatch\n"); +#endif + return; + } + + const uint8_t switch1 = cfg[GT911_MODULE_SWITCH1_OFF]; + if ((switch1 & GT911_INT_MODE_MASK) != GT911_INT_LOW_LEVEL) { + const uint8_t patched = static_cast((switch1 & ~GT911_INT_MODE_MASK) | GT911_INT_LOW_LEVEL); + // Config_Version 0x00 = apply without burning the table into the + // controller's own NVM, so the panel's factory config returns on the next + // reset and this can never permanently alter the hardware. Cost: the mode + // has to be re-applied on every boot (this runs from beginGt911()). + sum = static_cast(sum - cfg[0] - switch1 + patched); + const uint8_t version = 0x00; + const uint8_t tail[2] = {static_cast(-sum), 0x01}; // Config_Chksum, Config_Fresh + if (!gt911WriteReg(GT911_CONFIG_START, &version, 1) || !gt911WriteReg(0x804D, &patched, 1) || + !gt911WriteReg(0x80FF, tail, sizeof(tail))) { + return; + } + delay(10); // controller re-reads the table on the Config_Fresh write + } + + uint8_t readback = 0; + if (!gt911ReadReg(0x804D, &readback, 1) || (readback & GT911_INT_MODE_MASK) != GT911_INT_LOW_LEVEL) { + // Some panels reject a version-0x00 config. Report the INT as unusable + // rather than arm a wake on a line that only pulses. +#ifdef TOUCH_PROBE_DEBUG + touchDebugPrintf("[touch] GT911 int-wake: mode not applied (0x%02X)\n", readback); +#endif + return; + } + + // The reset dance leaves INT floating as a plain INPUT. A low-level INT is + // released between frames, so pull it up: an open-drain module then still + // reads HIGH at idle and the level wake cannot false-trigger. + pinMode(t.irq, INPUT_PULLUP); + touchWakeIrqUsable = true; +#ifdef TOUCH_PROBE_DEBUG + touchDebugPrintf("[touch] GT911 int-wake: INT=%d low-level hold\n", t.irq); +#endif +} + +#endif // FREEINK_GT911_INT_WAKE + void InputManager::pollGt911(const unsigned long now) { if (gt911Addr == 0) { return; From cdba235965f35061720633b337593dc278c71d96 Mon Sep 17 00:00:00 2001 From: Erica Jensen Date: Fri, 14 Aug 2026 21:56:09 -0700 Subject: [PATCH 2/2] FREEINK_GT911_INT_WAKE: verify config consumption and clear status after fresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the INT-wake path found the success test too weak. The read-back of Module_Switch1 (0x804D) proves only that the I2C write landed: the register holds whatever was written to it whether or not the controller ever re-read the table, so a panel that ignores a version-0x00 config would still pass and the app would arm a level wake on a line that only pulses. The controller's own acknowledgement is Config_Fresh (0x8100), which it clears once it has actually consumed the block — now polled for up to 100 ms in 10 ms steps, with a still-set flag treated as a refusal (the existing clean fallback: touchWakeIrqUsable stays false, the accessor keeps reporting -1, the app keeps polling). The 0x804D check is kept as a second guard. Re-reading the config makes the controller latch a report, and in low-level mode a pending report holds INT down until the status register is cleared, so the last step before declaring the line usable is a 0x814E clear. Without it the app arms its first level wake on an already-asserted pin. TouchConfig::irqActiveLow had no readers and disagreed with the hardcoded `return true` in touchWakeIrqActiveLow(): the X4 Pro profile said false, while the GT911 INT in low-level mode asserts LOW. The profile value was simply wrong for this use, so it is corrected to true (with a comment) and the accessor now consults it, which also makes the field live. Since the driver only ever programs a low-level hold, gt911ConfigureIntWake() refuses to configure a profile that claims active-high rather than let a host arm a high-level wake on a line that idles high — a permanent wake trigger. Also drops the stale "GPIO2 ... role unknown; not modeled here" note in the X4 Pro profile: GPIO2 is the touch rail, already modeled as powerEnable=2 / powerEnableActiveHigh=false a few lines above. Two read-only accessors the host's idle-wait gate needs, both in service of the same wake path: isHomeKeyDown(), because a motionless home-key hold emits no frames and its long-press threshold is timed by update() against the wall clock (a host that stops polling stretches it); and hasButtonHook(), so a host can tell that some of its buttons sit behind an I2C expander and are invisible to a GPIO interrupt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UUgQffv7GGVCWQ9gijsVbp --- .../BoardConfig/include/BoardConfig.h | 15 +++-- .../InputManager/include/InputManager.h | 13 +++- .../InputManager/src/InputManager.cpp | 59 +++++++++++++++++-- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/libs/hardware/BoardConfig/include/BoardConfig.h b/libs/hardware/BoardConfig/include/BoardConfig.h index ee948fe0..b74c62ac 100644 --- a/libs/hardware/BoardConfig/include/BoardConfig.h +++ b/libs/hardware/BoardConfig/include/BoardConfig.h @@ -490,7 +490,11 @@ struct TouchConfig { uint16_t rawMinY, rawMaxY; bool synthesizeConfirm; // emit a CONFIRM button event on tap uint8_t i2cAddressAlt; // alternate I2C address to probe (GT911 0x14; 0 = none) - bool irqActiveLow; // touch IRQ asserted LOW (CHSC6x) + // Asserted level of the irq pin (true = LOW). Reported by + // InputManager::touchWakeIrqActiveLow() so a host can arm the right level as a + // light-sleep wake source; the GT911 INT-wake path also refuses to configure a + // profile that claims active-high, since it only programs a low-level hold. + bool irqActiveLow; // GT911 point-frame layout: false = datasheet standard (track-id at 0x8150, so // coords start at byte 1); true = coords start at byte 0 (no track-id), as seen // on M5Paper's GT911 which boots without a reset/config dance. Ignored (CHSC6x). @@ -1406,6 +1410,8 @@ constexpr BoardProfile XTEINK_X4_PRO = { // internal config on the standard reset dance — no host config upload needed. Mounted PORTRAIT // (reports X:0..480, Y:0..800) on the 800x480 landscape panel → swapXY=true; rawMax describe the // post-swap panel axes. Coords start at byte 0 of the 0x8150 read → gt911CoordsAtByte0=true. + // irqActiveLow=true: the GT911 INT asserts LOW in the low-level hold mode the driver programs + // for wake (FREEINK_GT911_INT_WAKE) and idles HIGH on its pull-up between report frames. // flipX/flipY pending a corner-tap test. {ctrl,sda,scl,irq,rst,addr,rawMinX,rawMaxX,rawMinY,rawMaxY, // synthConfirm,altAddr,irqActiveLow,coordsAtByte0,powerEnable,swapXY,flipX,flipY,hasHomeKey,pwrActiveHigh} {TouchController::Gt911, @@ -1420,7 +1426,7 @@ constexpr BoardProfile XTEINK_X4_PRO = { 479, false, 0x14, - false, + true, true, 2, true, @@ -1463,8 +1469,9 @@ constexpr BoardProfile XTEINK_X4_PRO = { // board_begin at IROM 0x420a23dc). Carried as power.latch0 so holdPowerRails() asserts it // early — without it the panel rail and the SD slot both stay unpowered (the bring-up // symptom: EPD BUSY never asserts, SD returns 0xFF). GPIO2 is a second board-init output - // driven LOW (role unknown); not modeled here. NOTE: GPIO1/GPIO2 are therefore NOT the ADC - // button ladder — that earlier assumption was wrong; the ladder pins remain unconfirmed. + // driven LOW: the touch controller's active-LOW rail, modeled above as the TouchConfig + // powerEnable=2 / powerEnableActiveHigh=false pair. NOTE: GPIO1/GPIO2 are therefore NOT the + // ADC button ladder — that earlier assumption was wrong; the ladder pins remain unconfirmed. {1}, 0, // displayControllerVariant: filled by the boot probe // Bezel overlap: the panel sits recessed, and 7px is the empirically-tuned diff --git a/libs/hardware/InputManager/include/InputManager.h b/libs/hardware/InputManager/include/InputManager.h index 63d2d9b8..d598602f 100644 --- a/libs/hardware/InputManager/include/InputManager.h +++ b/libs/hardware/InputManager/include/InputManager.h @@ -140,6 +140,11 @@ class InputManager { // (~700 ms), while still down — a hold shortcut (e.g. open the reader menu). // Cleared each #update(). bool wasHomeKeyLongPressed() const; + // Home key currently held (level, not an edge). A motionless hold produces + // no new-data frames, so its long-press threshold is timed by #update() + // against the wall clock — a host that stops polling while this is true + // stretches that threshold by however long it stayed away. + bool isHomeKeyDown() const; // Touch INT line usable as a light-sleep wake source, or -1. A host can only // wake from light sleep on a GPIO LEVEL (the edge detector is clock-gated @@ -150,7 +155,9 @@ class InputManager { // FREEINK_GT911_INT_WAKE not built, or the controller refused the mode); // callers must keep polling in that case. int8_t touchWakeIrqPin() const; - // Asserted level of #touchWakeIrqPin (true = LOW). + // Asserted level of #touchWakeIrqPin (true = LOW), from the board profile's + // TouchConfig::irqActiveLow. The driver only ever programs a low-level hold, + // and refuses to report the pin at all on a profile that claims otherwise. bool touchWakeIrqActiveLow() const; // Optional board hook for buttons that aren't direct GPIOs — e.g. a key @@ -160,6 +167,10 @@ class InputManager { // none. using ButtonHook = uint8_t (*)(); static void setButtonHook(ButtonHook hook) { s_buttonHook = hook; } + // True once a board has installed such a hook. Lets a host tell that some of + // its buttons are NOT readable as GPIO levels — e.g. a GPIO-interrupt idle + // wait cannot see them and must keep polling instead. + static bool hasButtonHook() { return s_buttonHook != nullptr; } // Boards such as Sticky wire OK/confirm and power/wake to the same GPIO. By // default a short click emits CONFIRM and a hold emits POWER. Apps that diff --git a/libs/hardware/InputManager/src/InputManager.cpp b/libs/hardware/InputManager/src/InputManager.cpp index 79a13820..3a480d93 100644 --- a/libs/hardware/InputManager/src/InputManager.cpp +++ b/libs/hardware/InputManager/src/InputManager.cpp @@ -513,8 +513,17 @@ int8_t InputManager::touchWakeIrqPin() const { #endif } -// Only the GT911 low-level mode is programmed today, so the answer is constant. -bool InputManager::touchWakeIrqActiveLow() const { return true; } +bool InputManager::touchWakeIrqActiveLow() const { +#if FREEINK_CAP_TOUCH + // The board profile owns the polarity. gt911ConfigureIntWake() refuses to + // mark the line usable unless the profile agrees with the low-level hold it + // programs, so a caller that armed on touchWakeIrqPin() cannot read a + // polarity the driver did not actually configure. + return BoardConfig::ACTIVE.touch.irqActiveLow; +#else + return true; +#endif +} InputManager::TouchPoint InputManager::getTouchPoint() const { return touchPoint; } bool InputManager::isTouchPressed() const { return touchPressed; } @@ -689,6 +698,8 @@ bool InputManager::wasHomeKeyTapped() const { return touchHomeKeyTapEvent; } bool InputManager::wasHomeKeyLongPressed() const { return touchHomeKeyLongEvent; } +bool InputManager::isHomeKeyDown() const { return touchHomeKeyDown; } + void InputManager::beginTouch() { #if FREEINK_CAP_TOUCH const auto& t = BoardConfig::ACTIVE.touch; @@ -1398,6 +1409,11 @@ constexpr uint8_t GT911_CONFIG_CHUNK = 64; // Wire's buffer is 128 bytes constexpr uint16_t GT911_MODULE_SWITCH1_OFF = 0x804D - GT911_CONFIG_START; constexpr uint8_t GT911_INT_MODE_MASK = 0x03; constexpr uint8_t GT911_INT_LOW_LEVEL = 0x02; +constexpr uint16_t GT911_CONFIG_FRESH = 0x8100; +// The controller clears Config_Fresh when it has actually re-read the table. +// Datasheet flows allow it a few ms; give it 100 ms before calling it refused. +constexpr uint8_t GT911_FRESH_POLL_MS = 10; +constexpr uint8_t GT911_FRESH_POLL_TRIES = 10; } // namespace bool InputManager::gt911WriteReg(const uint16_t reg, const uint8_t* buf, const uint8_t len) { @@ -1415,6 +1431,16 @@ void InputManager::gt911ConfigureIntWake() { if (gt911Addr == 0 || t.irq < 0) { return; } + if (!t.irqActiveLow) { + // Only the low-level hold is programmed below, i.e. an INT that asserts + // LOW. A profile that describes its INT as active-high disagrees with what + // this would configure, so fail closed rather than let a caller arm a + // high-level wake on a line that idles high (a permanent wake trigger). +#ifdef TOUCH_PROBE_DEBUG + touchDebugPrintf("[touch] GT911 int-wake: profile says INT active-high\n"); +#endif + return; + } uint8_t cfg[GT911_CONFIG_LEN + 1] = {}; // config block + its stored checksum for (uint16_t off = 0; off < sizeof(cfg); off += GT911_CONFIG_CHUNK) { @@ -1452,13 +1478,31 @@ void InputManager::gt911ConfigureIntWake() { !gt911WriteReg(0x80FF, tail, sizeof(tail))) { return; } - delay(10); // controller re-reads the table on the Config_Fresh write + + // Wait for the controller to CONSUME the table, not just to acknowledge + // the write: it clears Config_Fresh itself once it has re-read the block. + // The 0x804D read-back below cannot stand in for this — that register + // holds whatever was written to it whether or not the config was applied, + // so it only proves the I2C transfer landed. A fresh flag still set after + // the timeout means the panel refused the version-0x00 table. + bool consumed = false; + for (uint8_t tries = 0; !consumed && tries < GT911_FRESH_POLL_TRIES; ++tries) { + delay(GT911_FRESH_POLL_MS); + uint8_t fresh = 1; + consumed = gt911ReadReg(GT911_CONFIG_FRESH, &fresh, 1) && fresh == 0; + } + if (!consumed) { +#ifdef TOUCH_PROBE_DEBUG + touchDebugPrintf("[touch] GT911 int-wake: Config_Fresh not cleared\n"); +#endif + return; + } } uint8_t readback = 0; if (!gt911ReadReg(0x804D, &readback, 1) || (readback & GT911_INT_MODE_MASK) != GT911_INT_LOW_LEVEL) { - // Some panels reject a version-0x00 config. Report the INT as unusable - // rather than arm a wake on a line that only pulses. + // Second guard, on top of the Config_Fresh clear above: the mode the + // controller now reports has to be the one asked for. #ifdef TOUCH_PROBE_DEBUG touchDebugPrintf("[touch] GT911 int-wake: mode not applied (0x%02X)\n", readback); #endif @@ -1469,6 +1513,11 @@ void InputManager::gt911ConfigureIntWake() { // released between frames, so pull it up: an open-drain module then still // reads HIGH at idle and the level wake cannot false-trigger. pinMode(t.irq, INPUT_PULLUP); + // In low-level mode a pending report holds INT down until the status + // register is cleared, and re-reading the config table makes the controller + // latch one. Clear it so the line idles released and the app's first level + // wake is armed on a quiet pin instead of an already-asserted one. + gt911ClearStatus(); touchWakeIrqUsable = true; #ifdef TOUCH_PROBE_DEBUG touchDebugPrintf("[touch] GT911 int-wake: INT=%d low-level hold\n", t.irq);