Understanding the XPT2046 touch controller

In this post I’m going to deep dive into the world of XPT2046. The idea is to understand how this controller works and come up with ways to make use of this module as an effective touch screen controller. I’m going to use the RpiPicoW as my companion board along with another RpiPicoW as a SWD adapter.

The XPT2046 I have came along with an ST7789 display which I purchased from Ali-Express. It’s very unlikely that these products are genuine. At times, I will be using the datasheet of ADS7846 touch screen controller to understand the controller as I felt the XPT2046 datsheet is a bit difficult to read. These two touch screen controllers seem to be almost similar.

I’m not going to go into the theory of operation, rather, I’m going to run some experiments to figure out how the touch readings change under different use cases, and come up with a firmware design to read the controller.

Please go through this post on how to setup the RpiPicoW module for XPT2046.

Experiment 1: Read every second without considering the interrupts

Firstly, I want to read the touch screen controller every second and analyse the reading.

Read without touching the screen

When I read the touch screen using the commands 0x90 and 0xD0 for x and y coordinates respectively, I received 0x0000 and 0x7ff8 as the output. I couldn’t understand the 0x7ff8 reading straightaway. I expected either 0x0000 or 0x0fff as it is a 12 bit reading. However, a closer look at the timing diagrams cleared this up for me:

  1. First 8 clock pulses from the master contains the command code (ie: 0x90 or 0xD0)
  2. The A/D conversion takes place in the next 12 clock cycles, however, the last bit comes out on the 13th clock cycle on differential (DFR) readings. When looking at the timing diagram, we can see that the slave starts pushing data to the bus in the 2nd clock. Therefore, data transfer takes places within the 2nd to 13th clocks inclusive.
  3. Last 3 clock cycles has the DOUT as zero.

Therefore, I ended up adjusting the read code to convert the reading as follows.

static uint16_t get_reading(const uint8_t* buffer)
{
    // Skip the first bit (MSb)
    // Read the next 12 bits
    uint16_t reading = (buffer[0] << 8) | buffer[1];
    reading = reading >> 3;
    printf("-%x %x-", buffer[0], buffer[1]);
    return reading;
}

Without touching the screen, I always receive (0, 4095) reading. I was expecting (0, 0) as there is no voltage across any planes.

However, further reading made it clear that this behaviour can be explained using the datasheet. The following diagram shows what signals are driven when we read the y coordinate.

table-5-y-reading

As you can see, we drive Y+, Y- signals, and connect X+ signal to the ADC. When this is analysed along with the PENIRQ interrupt circuit, we can see what goes on in the IC. When the measurement starts without a touch, there is no current path from X+ to ground. Any charge that is in the X+ signal gets into the ADC input.

figure-13-current-path

Therefore, the converter reads X+ as a HIGH signal with a digital reading of 4095.

While touching the screen

I use the same read command, but decided to touch and hold different parts of the screen and get the mean and the standard deviation of the readings.

The following code can be used to calculate the statistics:

int main()
{
    stdio_init_all();
    init_touch_screen();

    printf("Hello, world!\n");

    int count = 0;
    touch_point_t tps[100];
    while (true) {
        touch_point_t tp = read_touch_point();

        if (tp.x == 0 || tp.y == 4095 || count == 100) {
            if (count > 0) {
                double mean_x = 0.0;
                double mean_y = 0.0;

                for (int i = 0; i < count; i++) {
                    mean_x += (double)tps[i].x;
                    mean_y += (double)tps[i].y;
                }

                mean_x /= (double)count;
                mean_y /= (double)count;

                double sd_x = 0.0;
                double sd_y = 0.0;

                for (int i = 0; i < count; i++) {
                    const double var_x = (double)(tps[i].x) - mean_x;
                    sd_x += (var_x * var_x);
                    const double var_y = (double)(tps[i].y) - mean_y;
                    sd_y += (var_y * var_y);
                }

                sd_x /= (double)count;
                sd_y /= (double)count;

                printf("Reading complete: \n");
                printf("\t X Mean: %f\n", mean_x);
                printf("\t Y Mean: %f\n", mean_y);
                printf("\t X SD: %f\n", sqrt(sd_x));
                printf("\t Y SD: %f\n", sqrt(sd_y));
            }

            count = 0;
        }
        else
        {
            tps[count++] = tp;
        }

        sleep_ms(10);
    }
}

Touching the top left corner:

X Mean: 3331.090000
Y Mean: 488.140000
X SD: 112.898724
Y SD: 6.162824

Touching the top right corner:

X Mean: 540.550000
Y Mean: 486.400000
X SD: 21.912268
Y SD: 7.448490

Touching the bottom left corner:

X Mean: 3303.090000
Y Mean: 3581.620000
X SD: 109.312497
Y SD: 49.778264

Touching the bottom right corner:

X Mean: 419.840000
Y Mean: 3602.380000
X SD: 15.218226
Y SD: 50.388844

This gives us rough measurements of where the edges of the screen is. Based on this experiment, I can easily read the touch coordinates and filter out touch events. However, reading the touch controller all the time is not optimal. It wastes a lot of precious CPU cycles, and depending on the read-rate, we can even miss touch events.

Experiment 2: The touch interrupt

I beleive the most natural way to read a touch controller would be to first wait for the touch interrupt, then read the touch coordinates. Therefore, in this experiment I will be looking into the touch interrupt.

Touch interrupt signal

Since the datasheet talks about the PENIRQ being disabled during measurement cycle, I first decided to just look at the PENIRQ signal in a scope without reading the sensor. It’s important to note that I set the control bits to all zeros during the initialisation. This enables the interrupt pin.

The scope trace was not that interesting. I see a HIGH signal when there is no touch and a LOW signal on touch. The important point is, the signal remains LOW for the duration of the touch. However, depending on how strong the touch is, I can see some bouncing in the interrupt line.

Please refer to the traces below:

without bouncing with bouncing with bouncing

I incremented an interrupt counter in the interrup service routine to see how many interrupts I receive and it was obvious that I receive more than one interrupt per touch. The lowest interrupts per touch I could get was two.

Read on touch interrupt

Next, I would like to read the touch coordinates as soon as we receive the interrupt. For this, I am going to do the following,

  1. Set a flasg on touch interrupt
  2. Sample the flag within the main loop, read the sensor, and clear the flag

// main loop
if (touch_detected)
{
    printf("---------------------\n");
    touch_point_t tp = read_touch_point();
    printf("[%d] Reading complete: \n", count);
    printf("  (X, Y): (%d, %d)\n", tp.x, tp.y);
    count++;
    touch_detected = false;
    printf("IRQ COUNT: %d\n", irq_count);
    prev_irq_count = irq_count;
    printf("\n");
}

Looking closely at the interrupt signal through a scope, I can observe that it toggles during the read. The interrupt counter also proved this point.

Please have a look at the output below:

[1] Reading complete:
  (X, Y): (1514, 2503)
IRQ COUNT: 3

---------------------
[2] Reading complete:
  (X, Y): (0, 4095)
IRQ COUNT: 8

---------------------
[3] Reading complete:
  (X, Y): (1312, 2523)
IRQ COUNT: 11

---------------------
[4] Reading complete:
  (X, Y): (0, 4095)
IRQ COUNT: 23

---------------------
[5] Reading complete:
  (X, Y): (2093, 2335)
IRQ COUNT: 26

---------------------
[6] Reading complete:
  (X, Y): (0, 4095)
IRQ COUNT: 29

Everytime, I read the sensor, the IRQ COUNT increments by some random value. There can be many reasons for this behaviour.

  1. A soft touch can bounce the interrupt pin.
  2. During the measurement cycle, the PENIRQ pin is disabled and is kept LOW. Therefore, depending on when we do the measurement we can receive additional falling edge triggered interrupt/s.

The datasheet suggests masking the interrupt during the measurement cycle to avoid unintentional interrupts. Doing so completely eliminated the extra interrupts:

void touch_irq()
{
    // We don't enable interrupts until we complete the reading.
    gpio_acknowledge_irq(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL);
    gpio_set_irq_enabled(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL, false);
    touch_detected = true;
    irq_count++;
}

// ....

        if (touch_detected)
        {
            printf("---------------------\n");
            touch_point_t tp = read_touch_point();
            printf("[%d] Reading: \n", count);
            printf("  (X, Y): (%d, %d)\n", tp.x, tp.y);
            count++;
            printf("IRQ COUNT: %d\n", irq_count);
            touch_detected = false;
                gpio_set_irq_enabled(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL, true);
            }
        }
---------------------
[1] Reading complete:
  (X, Y): (2048, 2159)
IRQ COUNT: 1

---------------------
[2] Reading complete:
  (X, Y): (0, 4095)
IRQ COUNT: 2

---------------------
[3] Reading complete:
  (X, Y): (1868, 2335)
IRQ COUNT: 3

---------------------
[4] Reading complete:
  (X, Y): (0, 4095)
IRQ COUNT: 4

---------------------
[5] Reading complete:
  (X, Y): (1903, 2571)
IRQ COUNT: 5

---------------------
[6] Reading complete:
  (X, Y): (0, 4095)
IRQ COUNT: 6

Looking at the readings, every 2nd reading looks same as the output we receive if we read the controller without touching the screen. However, I don’t think it’s a guaranteed behaviour.


Next we will look at different ways we can read the controller and obtain useable readings. We know one thing for sure, we must mask the PENIRQ during the measurement cycle.

Method 1: Read until we detect (0, 4095) reading.

Since we know that (0, 4095) value corresponds to reads while not touching the screen, we can read in a loop until we receive the value (0, 4095). We can average all the valid readings to calculate the most accurate touch point. However, this wouldn’t let us implement motions such as touch and drag.

Since we already know the boundary values we can safely ignore any values that are outside the valid range. I’m not sure whether we’ll receive such values, but I feel it’s a reasonable design.

#define MIN_TOUCH_X  (400)
#define MAX_TOUCH_X  (3400)
#define MIN_TOUCH_Y  (400)
#define MAX_TOUCH_Y  (3500)

static touch_point_t read_touch_point()
{
    ...
    touch_point_t tp = {.x = reading_x, .y = reading_y, .valid = true};
    if (reading_x <= MIN_TOUCH_X || reading_x >= MAX_TOUCH_X || reading_y <= MIN_TOUCH_Y || reading_y >= MAX_TOUCH_Y)
    {
        tp.valid = false;
    }
    gpio_put(GPIO_SPI1_CSn, true);
    ...
}

// main loop
if (touch_detected)
{
    printf("---------------------\n");
    touch_point_t tp = read_touch_point();
    printf("[%d] Reading: \n", count);
    printf("  (X, Y): (%d, %d)\n", tp.x, tp.y);
    count++;
    printf("IRQ COUNT: %d\n", irq_count);
    prev_irq_count = irq_count;
    printf("\n");

    if (tp.valid)
    {
        readings++;
        sum_x += tp.x;
        sum_y += tp.y;
    }
    else if (tp.x == 0 && tp.y == 4095)
    {
        const int final_x = sum_x / readings;
        const int final_y = sum_y / readings;
        sum_x = 0;
        sum_y = 0;
        readings = 0;

        printf("Reading complete:\n");
        printf("   ===(X, Y): (%d, %d)", final_x, final_y);
        touch_detected = false;
        gpio_set_irq_enabled(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL, true);
    }
}

Pros:

  • Implementation is not very complex.
  • Readings are stable.

Cons:

  • This is still semi-polling. It would be better to use interrupts to perform the read.
  • Touch and drag operations would result in incorrect readings.
  • If we touch the screen for a long time sum_x and sum_y could overflow.

Method 2: Create a moving average of 10 readings.

Two of the drawbacks in the 1st method is related to indefinitely updating the sum_x and sum_y variables. Here, I’m going to implement a moving average of 10 readings to determine the coordinates.

To do this I wrote a simple windowing averager,

This is the interface:

// averager.h

#ifndef _AVERAGER_H
#define _AVERAGER_H

void averager_reset();
int averager_add_sample(const unsigned int x, const unsigned int y);
void averager_read(int *x, int *y);

#endif   // _AVERAGER_H

This is the implementation:

#include "averager.h"
#include <stdio.h>

#define AVERAGER_MAX_SAMPLE_COUNT    (10)

static int readings_x[AVERAGER_MAX_SAMPLE_COUNT];
static int readings_y[AVERAGER_MAX_SAMPLE_COUNT];
static unsigned int reading_idx = 0;
static unsigned int has_data = 0;

void averager_reset()
{
    for (int i = 0; i < AVERAGER_MAX_SAMPLE_COUNT; i++) {
        readings_x[i] = 0;
        readings_y[i] = 0;
    }
    reading_idx = 0;
    has_data = 0;
    printf("Averager reset\n\r");
}

int averager_add_sample(const unsigned int x, const unsigned int y)
{
    const unsigned int idx = reading_idx % AVERAGER_MAX_SAMPLE_COUNT;
    readings_x[idx] = x;
    readings_y[idx] = y;
    reading_idx++;
    has_data = 1;
    printf("Reading[%d] = (%d, %d)\n\r", reading_idx, x, y);

    return reading_idx >= 10;
}

void averager_read(int *x, int *y)
{
    if (!x || !y) {
        return;
    }

    if (has_data == 0 || reading_idx < 10) {
        *x = 0;
        *y = 0;
        printf("AVG = 0,0\n\t");
        return;
    }

    unsigned int sum_x = 0;
    unsigned int sum_y = 0;
    for (unsigned int i = 0; i < AVERAGER_MAX_SAMPLE_COUNT; i++) {
        sum_x += readings_x[i];
        sum_y += readings_y[i];
    }

    *x = sum_x / AVERAGER_MAX_SAMPLE_COUNT;
    *y = sum_y / AVERAGER_MAX_SAMPLE_COUNT;
    printf("AVG = %d, %d\n\r", *x, *y);
}

Then I connected the new averager to my main loop to submit readings.

// Main loop
    while (true) {
        if (touch_detected)
        {
            touch_point_t tp = read_touch_point();

            if (tp.valid)
            {
                const int has_reading = averager_add_sample(tp.x, tp.y);
                if (has_reading) {
                    int final_x;
                    int final_y;
                    averager_read(&final_x, &final_y);
                    printf("   ===(X, Y): (%d, %d)\n", final_x, final_y);
                }
            }
            else if (tp.x == 0 && tp.y == 4095)
            {
                printf("Reading complete:\n");
                averager_reset();
                touch_detected = false;
                gpio_set_irq_enabled(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL, true);
            }
        }
    }

This solution works really well. Now when I touch the screen, the averager acts as a low pass filter smoothing out readings. When I release the touch screen, I read one more reading and reset the averager.

Pros:

  • Implementation is not very complex.
  • Readings are very stable.
  • Touch and drag operations would still work.

Cons:

  • This is still semi-polling. It would be better to use interrupts to perform the read.

Method 3: Use a timer to read the touch screen

The method 2 would work for most use cases. However, I feel that an action akin to touching a screen should not be managed through polling. If the solution is appropriate to the use case, there is no need to over-complicate and over engineer the solution.

In this iteration, I’m going to move the entire touch controller logic to its own timer. This removes touch processing from the main loop. This is important because when we include more stuff in the main loop, the frequency in which we read the touch screen could reduce. Users could notice this as touch screen is infact user interface. By moving the touch screen processing to a timer, we guarentee the frequency in which we read the touch controller. Therefore, the load in the main loop is not going to impact the detection of touch events.

This introduces a bit of complexity to the design. The main reason being the touch controller is now read within an interrupt routing. Therefore, the touch coordinates must be shared with the rest of the code in a thread safe manner.

XPT2046 with timer

The RpiPicoW makes it very easy to implement this change.

First we need the following queue and timer.

#include "pico/time.h"
#include "pico/util/queue.h"

// ...

static queue_t touch_readings_queue;
static repeating_timer_t touch_screen_timer;

Then we should change the init_touch_screen function to include the queue initialisation. Here, we use the pico highlevel API to create a queue that can store 10 touch readings. This is a random depth value.

void init_touch_screen()
{
    // ...

    averager_reset();
    const unsigned int queue_depth = 10;
    queue_init(&touch_readings_queue, sizeof(touch_point_t), queue_depth);
}

Next, we need to change the touch interrupt such that it starts the timer on touch. The repeating timer is also a high level concept exposed through pico API. This basically wraps a hardware timer using an user friendly API. The touch_screen_read_callback function gets called every 10 ms.

void touch_irq()
{
    // We don't enable interrupts until we complete the reading.
    gpio_acknowledge_irq(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL);
    gpio_set_irq_enabled(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL, false);

    const int delay_10_ms = 10;
    add_repeating_timer_ms(delay_10_ms, touch_screen_read_callback, NULL, &touch_screen_timer);
}

Then, we should implement the touch_screen_read_callback to read the touch screen. This is where the touch controller is read. This is almost same as what we did in method 2. The main change is how we save the readings. As you can see, we add the reading into a queue. This is a non-blocking push into the queue which is shared between this callback and the touch consumer. We need this queue to make sure that the touch coordinates are consumed in a thread safe manner.

When we return false from this touch screen callback, it stops the timer.

bool touch_screen_read_callback(repeating_timer_t *rt)
{
    touch_point_t tp = read_touch_point();
    if (tp.valid)
    {
        const int has_reading = averager_add_sample(tp.x, tp.y);
        if (has_reading) {
            int final_x;
            int final_y;
            averager_read(&final_x, &final_y);
            touch_point_t temp_tp = {
                .valid = true,
                .x = final_x,
                .y = final_y
            };
            queue_try_add(&touch_readings_queue, &temp_tp);
        }
    }
    else if (tp.x == 0 && tp.y == 4095)
    {
        averager_reset();
        gpio_set_irq_enabled(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL, true);
        return false;
    }

    return true;
}

Finally, we change the main loop to consume the touch readings.

int main()
{
    // ...

    while (true)
    {
        touch_point_t tp;
        if (queue_try_remove(&touch_readings_queue, &tp)) {
            printf("Touch: (%d, %d)\n\r", tp.x, tp.y);
        }
    }
}

This is much cleaner design to process touch events.

We can further improve the solution by using SPI in interrupt mode instead of blocking mode. This is an improvement because what this means is we spend the least amount of time in the interrupt service routine maximising the time available to the processor to carry out other tasks outside this interrupt context.

Conclusion

This has been a fun blog post and I’ve learned so much preparing this. Hope you learned something new through this post. The following are the main points that stands out:

  1. The interrupts must be disabled during the measurement cycle of the XPT2046.
  2. XPT2046 data is clocked out from 2nd to 13th clock pulses inclusive.
  3. It’s better to sample the touch screen through a timer interrupt instead of going down the polling route.
  4. The screen edges do not correspond to full-scale readings.