Skip to content

feat: add GT911 touch controller support (config packet 0x28)#5

Merged
jonasniesner merged 2 commits into
OpenDisplay:mainfrom
balloob:feat/touch-gt911
Jul 8, 2026
Merged

feat: add GT911 touch controller support (config packet 0x28)#5
jonasniesner merged 2 commits into
OpenDisplay:mainfrom
balloob:feat/touch-gt911

Conversation

@balloob

@balloob balloob commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What

Ports the GT911 capacitive-touch subsystem from the Arduino reference firmware (OpenDisplay-Firmware src/touch_input.cpp) to the nRF54 port. Touch is a verified nRF52840-baseline feature and the nRF54 port had zero touch code, so this closes a genuine parity gap.

Adds config packet 0x28 (touch_controller, x4) parsing into a new 32-byte TouchController struct, a new opendisplay_touch.c/.h GT911 driver, and wire-up in the BLE main loop, the button subsystem, and the display refresh path.

Config packet 0x28

New struct TouchController (src/opendisplay_structs.h) matches the reference layout exactly (OpenDisplay-Firmware/src/structs.h:215-228) and the Python serializer (OpenDisplay-py-opendisplay/src/opendisplay/protocol/config_serializer.py:447-463). The parse case (src/opendisplay_config_parser.c) follows the existing led/binary_inputs pattern (max 4 instances, bounds-checked). Beyond adding the feature, giving 0x28 a real case stops it from hitting the default: skip-to-CRC branch (opendisplay_config_parser.c:506-509), which silently drops every packet ordered after it.

On-wire layout (verified: _Static_assert(sizeof == 32) + per-field offset asserts pass on host gcc)

Off Size Field Reference structs.h NRF54 opendisplay_structs.h Python serializer
0 1 instance_number uint8_t uint8_t B
1 2 touch_ic_type uint16_t (LE) uint16_t (LE) H
3 1 bus_id uint8_t uint8_t B
4 1 i2c_addr_7bit uint8_t uint8_t B
5 1 int_pin uint8_t uint8_t B
6 1 rst_pin uint8_t uint8_t B
7 1 display_instance uint8_t uint8_t B
8 1 flags uint8_t uint8_t B
9 1 poll_interval_ms uint8_t uint8_t B
10 1 touch_data_start_byte uint8_t uint8_t trailing byte
11 1 enable_pin uint8_t uint8_t (reserved[0] = 0)
12 20 reserved[20] reserved[20] reserved[20] reserved[1:21]

Total 32 bytes. The Python serializer packs touch_data_start_byte then 21 reserved bytes; its byte 11 is reserved[0] = 0, which equals enable_pin = 0 (unused). So the packet is byte-identical on the wire; enable_pin is only ever non-zero from a producer that models the field explicitly (as the C reference does).

GT911 driver (opendisplay_touch.c/.h)

Ports the reference behavior: I2C address probing (0x5D/0x14), INT/RST reset sequencing with INT-selected address (high-before-RST-release => 0x14, low => 0x5D), product-ID probe with LE/BE register-order auto-detect, status/point reads, and the lightweight probe-before-full-reset resume from reference PR #75 (touch_input.cpp:392-443). The MSD dynamic-byte encoding is copied exactly (touch_input.cpp:699-717): byte0 low nibble = contacts 1-5 (down) / 6 (released, last XY kept) / 0 (never touched, block cleared), high nibble = last track id, bytes 1-4 = last X (LE) then last Y (LE) - so HA and other clients decode touch identically on both firmwares.

I2C transport choice: software (bit-banged) open-drain master

Chosen over nrfx TWIM. Rationale: the touch bus pins arrive as runtime config bytes in the compact (port<<4)|pin encoding (src/nrf54_gpio.c nrf54_pin_decode), read from the data_bus (0x24) instance selected by TouchController.bus_id. Devicetree-fixed Zephyr I2C controllers can't bind arbitrary runtime PSEL pins, and the existing nrf54_gpio layer already gives clean open-drain primitives (input+pull-up to release high, output-low to drive low, plus a read). Bit-banging over those helpers keeps the whole port on one GPIO abstraction with no new Kconfig/devicetree/nrfx surface, and GT911 is happy at the resulting sub-100 kHz clock. The driver models its tick/integration style on opendisplay_led.c/opendisplay_button.c: non-blocking, cooperatively polled from the main loop. INT-driven wakeups are not used (poll-only is a fully supported reference mode); the INT pin is still driven during reset for address selection and reserved from the button subsystem.

Robustness

  • Every I2C transaction is a bounded bit-bang with a hard 1 ms clock-stretch timeout - it can never hang the main loop.
  • read/write retry 3x; 5 consecutive failures disable the controller, matching the reference.
  • process() is throttled to >=100 ms and per-controller poll_interval_ms; a worst-case pass (1-byte status + 8-byte point read) stays in the low-single-digit-ms range. All blocking reset/settle delays (300 ms pre / 200 ms post) run only at init / full-reset-resume, never in the per-tick path.

Wire-up

  • opendisplay_ble.c: opendisplay_touch_init() next to opendisplay_button_init() (after config load); opendisplay_touch_process() next to opendisplay_button_process() in the main loop. Touch feeds the MSD via opendisplay_ble_set_dynamic_byte() + opendisplay_ble_update_msd(true) + opendisplay_ble_boost_advertising(), exactly as opendisplay_button.c:103 does.
  • opendisplay_button.c: button init now skips any pin that is a configured GT911 INT (opendisplay_touch_gpio_is_touch_int), mirroring OpenDisplay-Firmware/src/device_control.cpp:572.
  • opendisplay_display.cpp: opendisplay_touch_resume_after_refresh() is called after both the partial and full direct-write refresh paths complete.

Refresh coordination

The reference wraps EPD refreshes in touchSuspendForEpdRefresh() / touchResumeAfterEpdRefresh() (main.cpp:78,177,209,221,235). The suspend half is not applicable here: on the nRF54 port the panel refresh is a synchronous blocking call inside the pipe handler, so the main loop (hence opendisplay_touch_process()) is not serviced during it and there is no concurrent touch access to gate. The bit-bang bus is also on independent GPIOs, so there is no EPD-SPI contention. The resume half is applicable (a refresh can perturb a GT911 that shares the panel power rail), so it is preserved: after a refresh the driver does the reference's lightweight product-ID probe, falling back to a full reset + re-init only if that fails.

Data-bus gating

The reference obtains its I2C pins from a data_bus (0x24) instance via TouchController.bus_id (touch_input.cpp:282-299). This port replicates that exactly: SCL = data_bus.pin_1, SDA = data_bus.pin_2, requiring bus_type == I2C and both pins set. Unlike the Arduino build there is no implicit shared Wire, so bus_id == 0xFF (reference "I2C already up") or a missing/invalid data_bus disables that controller with a log rather than guessing.

Testing

CI (PlatformIO, both seeed-xiao-nrf54l15 and seeed-xiao-nrf54lm20a) is green. Hardware caveat: implemented from the datasheet and the Arduino reference only - it has not been validated against a real GT911 panel on nRF54 hardware. The reset timing, address selection, and register access mirror the working reference, but a smoke test on a physical touch panel is still needed before relying on it.

Stacking

Stacked on PR #1 (ci: build both nRF54 environments). The branch is based on feat/ci. Note the sibling feat/buzzer (PR #3) also adds a config-parser case and a struct, so a textual merge conflict in opendisplay_config_parser.c / opendisplay_structs.h is expected and benign.

🤖 Generated with Claude Code

https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR

Port the GT911 touch subsystem from the Arduino reference firmware
(OpenDisplay-Firmware src/touch_input.cpp) to the nRF54 port.

- Parse config packet 0x28 (touch_controller, x4) into a 32-byte
  TouchController struct matching the reference and the py-opendisplay
  serializer on the wire. This also stops 0x28 from falling through to
  the default skip-to-CRC branch, which silently drops all later packets.
- New opendisplay_touch.c/.h GT911 driver: address probing (0x5D/0x14),
  hardware reset/address selection via INT/RST, product-ID probe,
  point reading, lightweight probe-before-full-reset on EPD-refresh
  resume, and byte-identical MSD dynamic-byte encoding.
- Software (bit-banged) open-drain I2C master over the existing
  nrf54_gpio helpers, since bus pins come from runtime config bytes.
- Wire-up: init/process next to the button module, reserve the touch
  INT pin from button init, feed MSD via opendisplay_ble_set_dynamic_byte,
  and re-probe touch after EPD refresh from the display path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR
@jonasniesner jonasniesner merged commit 5702a47 into OpenDisplay:main Jul 8, 2026
0 of 2 checks passed
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.

2 participants