Skip to content

feat: opt-in GT911 INT wake support (FREEINK_GT911_INT_WAKE) - #41

Open
hooligan333 wants to merge 2 commits into
Free-Ink:mainfrom
hooligan333:feat/gt911-int-wake
Open

feat: opt-in GT911 INT wake support (FREEINK_GT911_INT_WAKE)#41
hooligan333 wants to merge 2 commits into
Free-Ink:mainfrom
hooligan333:feat/gt911-int-wake

Conversation

@hooligan333

Copy link
Copy Markdown

Summary

The GT911's INT trigger mode is panel-self-loaded and typically edge-mode, which is unusable for ESP32-S3 light-sleep GPIO wake — the SoC's edge detector is clock-gated while asleep, so only a level can wake it. Under FREEINK_GT911_INT_WAKE (off by default), beginGt911() now:

  • reads the controller's config block (0x8047..0x80FE + its stored checksum at 0x80FF) and verifies the 8-bit two's-complement checksum before touching anything;
  • patches Module_Switch1 (0x804D) bits[1:0] to low level, recomputes the checksum and applies it with Config_Fresh, writing Config_Version = 0x00 so the table is applied but never burned to the panel's NVM (the factory config returns on the next reset; the cost is re-applying it on every boot);
  • verifies the controller actually CONSUMED the config — it polls Config_Fresh (0x8100) for up to 100 ms in 10 ms steps, because the 0x804D read-back alone only proves the I2C write landed, not that the table was re-read. 0x804D is kept as a second guard;
  • clears the status register (0x814E) last, since re-reading the config makes the controller latch a report and a pending report holds INT down in low-level mode.

Every step fails closed to "unusable": touchWakeIrqUsable stays false, touchWakeIrqPin() keeps returning -1, and the host keeps polling. A host can therefore never arm a level wake on a line that only pulses, or on a pin that is already asserted.

New accessors, all read-only:

  • touchWakeIrqPin() / touchWakeIrqActiveLow() — the wake pin and its asserted level. Polarity now comes from the board profile's TouchConfig::irqActiveLow (previously a dead field); the X4 Pro profile's false was simply wrong for this use and is corrected to true, and 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).
  • isHomeKeyDown() — a motionless home-key hold emits no frames and its long-press threshold is timed by update() against the wall clock, so a host that stops polling stretches it.
  • hasButtonHook() — lets a host tell that some of its buttons sit behind an I2C expander and are invisible to a GPIO interrupt.

Off by default; zero behavior change without the define — no config read, no write, no pin-mode change, and the accessor always reports -1.

Residual risks are documented in the commit messages: a panel that silently rejects a version-0x00 config keeps its factory INT mode (reported as unusable, logged under TOUCH_PROBE_DEBUG), and pollGt911()'s early return on an I2C read failure leaves 0x814E uncleared, so a bus fault holds INT asserted and suppresses sleep until it recovers.

Testing

Built against the X4 Pro firmware with FREEINK_GT911_INT_WAKE + the consuming app flag: SUCCESS. Default builds are unaffected (the code is entirely inside #ifdef).

On-device measurements

On-device PM-profiling comparison by the submitter (X4 Pro hardware; both runs CONFIG_PM_PROFILING dumps under an esp_pm/tickless test firmware — the mechanism being upstreamed here is the wake-source plumbing those runs exercised):

Metric Light sleep alone (#39 baseline) + touch/button INT wake
Sleep residency 92% 92% (with ~2× the active reading share)
Sleep entries/sec 18.2/s 4.5/s (4× fewer wakeups)
Average sleep window 50.6 ms 204 ms (4× longer)
Awake-idle time share ~2% ~0% (poll idling eliminated)
Touch I2C polls/sec 20.7/s 6.2/s (polls only while interacting)
Rejected sleep attempts 0 0

The residual ~4.5 wakes/s in the measured build traces to an unrelated background task's fallback timer, not this mechanism.

AI Usage: YES — Claude Code implementation and adversarial review (held-level busy-loop, notification hygiene, board gating, and GT911 config-consumption verification were all review-hardened); hardware testing and measurements by the submitter.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UUgQffv7GGVCWQ9gijsVbp

Erica Jensen and others added 2 commits August 15, 2026 12:13
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUgQffv7GGVCWQ9gijsVbp
…ter fresh

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUgQffv7GGVCWQ9gijsVbp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant