..

ST7789 LCD with Touch

I found a 2.8" Touchscreen IPS LCD Display for Raspberry Pi Pico (320x240) on my shelf. Must have bought it a while ago. But I have something in mind for it, so let’s explore a bit.

It’s the “ubiquitous” ST7789 (lots of product use this chip). There is also a lot of software for it, and what I have in mind can definitely be done with MicroPython. But why not investigate a bit?

The plan: A pomodoro timer. I use one on my phone, but having a phone can be a distraction in itself. Having dedicated hardware just for that could be better. Of course, with a Wi-Fi enabled Raspberry Pi Pico, who knows what distractions we can have. But for now, just ST7789 and a bit of touch for control.

ST7789

The datasheet can be found on the internet, I’m not going to link to one. I think they’re all pretty much the same (I’ve used one for ST7789VW successfully). This one uses SPI for communication.

First Step: Green Screen

I have some ST7789 code that I wrote some time ago. I’ll try to make it work and document it at the same time. There is also some documentation on https://www.waveshare.com/wiki/Pico-ResTouch-LCD-2.8.

The protocol is straightforward: write a command, then write data (if any).

  1. To start the communication, we lower the CS (chip select).
  2. To send a command, we set D/C (data/command) to 0.
  3. We then send the command (one byte).
  4. To send data, we set D/C to 1.
  5. We then send the data (as many bytes as we need).
  6. To finish, we raise the CS.

There are also some defaults:

  1. Memory data access control (set using the MADCTL command):
    1. Page address order: Top to bottom
    2. Column address order: Left to right
    3. Page/column order: normal mode
    4. Line address order: LCD refresh top to bottom
    5. RGB/BGR order: RGB
    6. Display data latch data order: LCD refresh left to right
  2. Colour mode (set using the COLMOD command):
    1. 18bit/pixel
    2. 262K of RGB interface (I’m guessing)

The memory data access control defaults are fine (for now), but 16bit/pixel is easier.

There is also a RESET command, but I’m not sure I need to use it.

So let’s start. First of all, setting up the pins:

#define SPI_PORT spi1
#define PIN_DC   8
#define PIN_CS   9
#define PIN_SCK  10
#define PIN_MOSI 11
#define PIN_MISO 12
    // SPI initialisation
    spi_init(SPI_PORT, 1000*1000);
    gpio_set_function(PIN_DC,   GPIO_FUNC_SIO);
    gpio_set_function(PIN_CS,   GPIO_FUNC_SIO);
    gpio_set_function(PIN_SCK,  GPIO_FUNC_SPI);
    gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);
    gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);

    // Chip select is active-low, so we'll initialise it to a driven-high state
    gpio_set_dir(PIN_CS, GPIO_OUT);
    gpio_put(PIN_CS, 1);

    // Data/command
    gpio_set_dir(PIN_DC, GPIO_OUT);

Green is a good colour - nice and bright. In 565 RGB, full green is 0000 0111 1110 0000, which is 0x07e0. If it doesn’t come out green, then we’re doing something wrong.

Define a few constants:

enum st7789_command_t {
    SWRESET  = 0x01,
    SLPOUT   = 0x11,
    INVOFF   = 0x20,
    INVON    = 0x21,
    DISPOFF  = 0x28,
    DISPON   = 0x29,
    CASET    = 0x2a,
    RASET    = 0x2b,
    RAMWR    = 0x2c,
    COLMOD   = 0x3a,
    MADCTL   = 0x36,
};

enum st7789_rgb_interface_t {
    ST7789_RGB_INTERFACE_65K   = 0x50,
    ST7789_RGB_INTERFACE_282K  = 0x60,
};

enum st7789_control_interface_t {
    ST7789_CONTROL_INTERFACE_12BPP  = 0x3,
    ST7789_CONTROL_INTERFACE_16BPP  = 0x5,
    ST7789_CONTROL_INTERFACE_18BPP  = 0x6,
    ST7789_CONTROL_INTERFACE_16M    = 0x7,
};

Leave sleep mode (without this, nothing happens):

    write_command(SLPOUT, NULL, 0);

Initialise colour mode:

    uint8_t buffer[1] = { ST7789_RGB_INTERFACE_65K | ST7789_CONTROL_INTERFACE_16BPP };
    write_command(COLMOD, buffer, sizeof buffer);

Turn the display on:

    write_command(DISPON, NULL, 0);

With write_command being:

static void write_command(uint8_t command, const uint8_t *data, size_t len)
{
    gpio_put(PIN_CS, 0);
    gpio_put(PIN_DC, 0);

    spi_write_blocking(SPI_PORT, &command, 1);

    if (data != NULL) {
        gpio_put(PIN_DC, 1);
        spi_write_blocking(SPI_PORT, data, len);
    }

    gpio_put(PIN_CS, 1);
}

See the explanation above for CS and DC.

Fill the screen green (very naive, but that’s OK for now):

    gpio_put(PIN_CS, 0);
    gpio_put(PIN_DC, 0);

    uint8_t command = RAMWR;
    spi_write_blocking(SPI_PORT, &command, 1);

    gpio_put(PIN_DC, 1);

    uint8_t green[2] = { 0x07, 0xe0 };
    for (int row = 0; row < 240; row++) {
        for (int col = 0; col < 320; col++) {
            spi_write_blocking(SPI_PORT, green, sizeof green);
        }
    }

    gpio_put(PIN_CS, 1);

And… it doesn’t work.

Maybe we need to use the backlight? Let’s see.

The answer is yes.

#define PIN_LCD_BL 13

...

    gpio_set_function(PIN_LCD_BL, GPIO_FUNC_SIO);

    // Backlight
    gpio_set_dir(PIN_LCD_BL, GPIO_OUT);
    gpio_put(PIN_LCD_BL, 1);

The result: the screen is a lovely purple hue. I tried to “erase” it by filling the screen with zeros, and got a lovely white screen. So it’s inverted.

    write_command(INVON, NULL, 0);

And now I’m getting green. At 1MHz it’s quite slow, not to mention this terrible one-pixel-at-a-time loop which doesn’t make sense. But I can see how the screen fills slowly from right to left (not top to botton). So there are a few more settings that we need to use if we choose to.

But for now, that’s all good.

More Control: Direction, Window

The ST7789 has many features. Next I would like to explore two:

  1. Direction - I’d like to treat the screen as top-to-bottom, left-to-right
  2. Window - I’d like to update just part of the screen rather than the entire screen

I don’t want to have a massive 320x240x2 byte buffer. Instead, updating just part of the screen makes much more sense in terms of memory usage and speed.

For point 1, I’m afraid that this is not possible. The display has 240 columns and 320 rows. Drawing direction depends on display orientation. If I want to use it as a “widescreen” display, I just need to map the pixels accordingly (x <-> y).

EDIT: I was wrong. I’m going to write a different post about this.

For the second point, there are two commands, CASET and RASET, which set column start/end and row start/end. The display should know what to do after each row (move to the start of the next row).

Using this works:

void set_viewport(int x, int y, int w, int h)
{
    uint8_t buffer[4];
    buffer[0] = x >> 8;
    buffer[1] = x & 0xff;
    int x_end = x + w - 1;
    buffer[2] = x_end >> 8;
    buffer[3] = x_end & 0xff;
    write_command(CASET, buffer, sizeof buffer);

    buffer[0] = y >> 8;
    buffer[1] = y & 0xff;
    int y_end = y + h - 1;
    buffer[2] = y_end >> 8;
    buffer[3] = y_end & 0xff;
    write_command(RASET, buffer, sizeof buffer);
}

All of this code is pretty naive - I’m just trying things. At some point I will tidy up a bit.

Setting the viewport allow me to blit more efficiently - not one pixel at a time, but a memory block at a time. Preparing the memory is still a bit of a chore because of the RGB565, but it’s OK.

### Count-Down Timer

The next thing is a count-down timer. Nothing new here, I’m going to use a buffer to hold each digit, and then just spi_write_blocking to write it. There are better way to do that, but it’s a reasonable start.

There is a nice repository of fonts at https://github.com/jdmorise/BMH-fonts, and I’m going to use Roboto Black because it’s nice (and is under the Apache License, Version 2.0)

Sadly this post is going to end abruptly. I’ve been playing with the fonts for a while, but I need to try a few other things first.

The next post is here.