st77916 0.1.0

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 (360×390, 262K color) with embedded-graphics support.

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" (360×360, 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:
    • Unbuffered (default) — caller owns pixel data, streams via send_pixels().
    • 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.
  • embedded-graphics DrawTarget implementation with optimized fill_solid, fill_contiguous, and clear.
  • RGB565, RGB888, and Gray8 color formats.
  • Full ST77916 command set from the datasheet (V1.0, Sitronix, 2022/08).

Usage

Unbuffered (default)

The caller owns the pixel data and streams it directly. No DrawTarget support unless .unbuffered::<COLOR>() is called on the builder.

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;

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)?;

// Draw with embedded-graphics
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{Circle, PrimitiveStyle};

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)?;

// Typical frame loop:
loop {
    // Draw into back buffer via DrawTarget...
    render_scene(&mut display);

    // Swap: back becomes front
    display.swap_buffers();

    // Flush front buffer (can overlap with next frame's render
    // if the interface supports non-blocking DMA)
    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 × 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).

This is the same framing used by the SH8601 and other similar controllers, so a QSPI implementation written for one will work with the other.

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. Many thanks to the author for the excellent reference implementation.

License

Licensed under either of

at your option.