st77916 0.1.1

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

st77916

A no_std Rust driver for the Sitronix ST77916 TFT-LCD display controller (360x390, 262K color).

The ST77916 is found in round and square LCD modules driven over QSPI, SPI, or parallel interfaces. It is used in boards such as the Waveshare ESP32-S3 Knob Touch LCD 1.8" (360x360, QSPI).

Features

  • Generic over communication interface — implement ControllerInterface for QSPI, 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 ST77916 command set from the datasheet (V1.0, Sitronix, 2022/08).

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]
st77916 = "0.1"

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

Usage

Unbuffered (no DrawTarget)

The caller owns the pixel data and streams it directly.

use st77916::{St77916, DisplaySize, ColorMode};

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

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

display.set_window(0, 0, 359, 359)?;
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 st77916::{St77916, 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(360, 360);
const FB_SIZE: usize = framebuffer_size(SIZE, ColorMode::Rgb565);

let mut display = St77916::builder(qspi_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. Draw operations target the back buffer; swap_buffers() exchanges front and back, then flush_front() sends the completed frame.

use st77916::{St77916, DisplaySize, ColorMode, Framebuffer, framebuffer_size};
use embedded_graphics::pixelcolor::Rgb565;

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

let mut display = St77916::builder(qspi_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..720].fill(0xFF); // Write to first row (360 pixels x 2 bytes)
display.mark_dirty(0, 0); // Mark row 0 as dirty
display.flush()?;

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()).

QSPI protocol

The ST77916 QSPI interface (datasheet Section 8.8.5) uses:

Opcode Mode Description
0x02 PP Single-line cmd + addr + data (register writes)
0x32 PP4O Single-line cmd + addr, quad-line data (pixel writes)
0x0B FAST READ Single-line read with dummy byte

Address format: [0x00, command_byte, 0x00] (24-bit).

Datasheet

The ST77916 datasheet (V1.0, 264 pages) is hosted by Espressif:

https://dl.espressif.com/AE/esp-iot-solution/ST77916_SPEC_V1.0.pdf

Acknowledgements

This crate is heavily inspired by theembeddedrustacean/sh8601-rs. The trait-based architecture (ControllerInterface, ResetInterface), builder pattern, framebuffer design, and embedded-graphics integration all follow the patterns established in that crate.

License

Licensed under either of

at your option.