st7789v2 0.1.0

A Rust driver for the ST7789V2 TFT-LCD display controller
Documentation

st7789v2

A no_std Rust driver for the Sitronix ST7789V2 TFT-LCD display controller (240x320, 262K color).

The ST7789V2 is a widely used single-chip controller for small TFT panels, commonly driven over SPI. It is found in boards such as the Waveshare ESP32-C6 1.69" LCD (240x240, SPI) and many other hobbyist and commercial display modules.

Features

  • Generic over communication interface — implement ControllerInterface for SPI, parallel, or any other bus.
  • Generic over reset pin — GPIO, I2C expander, or any other mechanism via ResetInterface.
  • Three buffering modes selected at compile-time via a generic parameter (requires the embedded-graphics feature):
    • Unbuffered DrawTarget — zero RAM, each draw is a hardware transaction.
    • Single-buffered — internal framebuffer with DrawTarget support and automatic dirty band tracking (only changed rows are flushed).
    • Double-buffered — two framebuffers for overlapping render and flush on hardware with separate CPU/DMA memory buses.
  • Builder pattern with with_init_commands() for vendor-specific panel init sequences.
  • Static or heap-allocated framebuffer.
  • RGB565, RGB888, and Gray8 color formats.
  • Full ST7789V2 command set from the datasheet (V1.0, Sitronix, 2016/11), including Command Table 2 (manufacturer registers).

Cargo features

Feature Default Description
embedded-graphics yes DrawTarget support, buffering modes, color trait

Without the feature the crate provides the core driver (commands, set_window, send_pixels, sleep, MADCTL, brightness) with zero dependency on embedded-graphics-core.

# Default — includes embedded-graphics integration
[dependencies]
st7789v2 = "0.1"

# Without embedded-graphics (raw driver only)
[dependencies]
st7789v2 = { version = "0.1", default-features = false }

Usage

Unbuffered (no DrawTarget)

The caller owns the pixel data and streams it directly.

use st7789v2::{St7789v2, DisplaySize, ColorMode};

const SIZE: DisplaySize = DisplaySize::new(240, 240);

let mut display = St7789v2::builder(spi_interface, gpio_reset, SIZE)
    .with_init_commands(&PANEL_INIT_SEQUENCE)
    .build(ColorMode::Rgb565, &mut delay)?;

display.set_window(0, 0, 239, 239)?;
display.send_pixels(&my_pixel_data)?;

Single-buffered (recommended)

Internal framebuffer with embedded-graphics DrawTarget. Draw operations write to RAM; flush() sends only the changed rows to the display.

use st7789v2::{St7789v2, DisplaySize, ColorMode, Framebuffer, framebuffer_size};
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{Circle, PrimitiveStyle};

const SIZE: DisplaySize = DisplaySize::new(240, 240);
const FB_SIZE: usize = framebuffer_size(SIZE, ColorMode::Rgb565);

let mut display = St7789v2::builder(spi_interface, gpio_reset, SIZE)
    .with_init_commands(&PANEL_INIT_SEQUENCE)
    .buffered::<Rgb565>(Framebuffer::heap::<FB_SIZE>())
    .build(ColorMode::Rgb565, &mut delay)?;

Circle::new(Point::new(100, 100), 50)
    .into_styled(PrimitiveStyle::with_fill(Rgb565::RED))
    .draw(&mut display)?;

// Flush only the dirty rows to the display
display.flush()?;

// Or force a full refresh (e.g. after wake from sleep)
display.full_flush()?;

Double-buffered

Two framebuffers allow overlapping render and flush on hardware with separate CPU and DMA memory buses.

use st7789v2::{St7789v2, DisplaySize, ColorMode, Framebuffer, framebuffer_size};
use embedded_graphics::pixelcolor::Rgb565;

const SIZE: DisplaySize = DisplaySize::new(240, 240);
const FB_SIZE: usize = framebuffer_size(SIZE, ColorMode::Rgb565);

let mut display = St7789v2::builder(spi_interface, gpio_reset, SIZE)
    .with_init_commands(&PANEL_INIT_SEQUENCE)
    .double_buffered::<Rgb565>(
        Framebuffer::heap::<FB_SIZE>(),
        Framebuffer::heap::<FB_SIZE>(),
    )
    .build(ColorMode::Rgb565, &mut delay)?;

loop {
    render_scene(&mut display);
    display.swap_buffers();
    display.flush_front()?;
}

Direct framebuffer access

For advanced use cases, the framebuffer can be accessed directly. After writing pixels manually, call mark_dirty() so the next flush() includes the modified rows.

let fb = display.framebuffer_mut();
fb[0..480].fill(0xFF); // Write to first row (240 pixels x 2 bytes)
display.mark_dirty(0, 0); // Mark row 0 as dirty
display.flush()?;

Implementing ControllerInterface for SPI

The ST7789V2 uses a standard SPI interface with a DC (data/command) pin. Here is a typical implementation pattern:

use st7789v2::ControllerInterface;

struct MySpiInterface<SPI, DC, CS> {
    spi: SPI,
    dc: DC,
    cs: CS,
    ramwr_sent: bool,
}

impl<SPI, DC, CS> ControllerInterface for MySpiInterface<SPI, DC, CS>
where
    SPI: embedded_hal::spi::SpiBus<u8>,
    DC: embedded_hal::digital::OutputPin,
    CS: embedded_hal::digital::OutputPin,
{
    type Error = core::convert::Infallible; // or your error type

    fn send_command(&mut self, cmd: u8) -> Result<(), Self::Error> {
        self.ramwr_sent = false;
        self.dc.set_low().ok();
        self.cs.set_low().ok();
        self.spi.write(&[cmd]).ok();
        self.cs.set_high().ok();
        Ok(())
    }

    fn send_command_with_data(&mut self, cmd: u8, data: &[u8]) -> Result<(), Self::Error> {
        self.ramwr_sent = false;
        self.dc.set_low().ok();
        self.cs.set_low().ok();
        self.spi.write(&[cmd]).ok();
        self.dc.set_high().ok();
        self.spi.write(data).ok();
        self.cs.set_high().ok();
        Ok(())
    }

    fn send_pixels(&mut self, pixels: &[u8]) -> Result<(), Self::Error> {
        if !self.ramwr_sent {
            self.dc.set_low().ok();
            self.cs.set_low().ok();
            self.spi.write(&[0x2C]).ok(); // RAMWR
            self.dc.set_high().ok();
            self.ramwr_sent = true;
        } else {
            self.dc.set_high().ok();
            self.cs.set_low().ok();
        }
        self.spi.write(pixels).ok();
        self.cs.set_high().ok();
        Ok(())
    }
}

Dirty band tracking

The single-buffered mode automatically tracks which rows have been modified by DrawTarget operations (draw_iter, fill_solid, fill_contiguous, clear). When flush() is called:

  • If nothing changed since the last flush, it returns immediately (no SPI traffic).
  • Otherwise, only the contiguous band of modified rows is sent — a single set_window + send_pixels call with no allocation.

This is transparent to all callers — the DrawTarget API is unchanged. For UIs where only a small region updates each frame (e.g. a clock or status bar), this can reduce flush time from ~12ms (full screen) to <1ms.

Use full_flush() to bypass dirty tracking when needed (e.g. after wake from sleep or direct framebuffer writes without mark_dirty()).

SPI protocol

The ST7789V2 uses standard 4-line SPI with a DC (data/command) pin:

  • DC low = command byte
  • DC high = data / pixel bytes
  • Commands: CASET (0x2A), RASET (0x2B), RAMWR (0x2C)
  • Pixel format set via COLMOD (0x3A): 0x55 = RGB565, 0x66 = RGB666

Datasheet

ST7789V2 Datasheet V1.0, Sitronix Technology Corporation, 2016/11 (319 pages).

License

Licensed under either of

at your option.