st7305-lcd 0.2.0

Platform-independent no_std driver for the Sitronix ST7305 reflective LCD controller (embedded-hal 1.0, blocking and async)
Documentation

st7305-lcd

A no_std driver for the Sitronix ST7305 monochrome reflective LCD controller, built on embedded-hal 1.0 and independent of any HAL or board.

  • Talks to the display through embedded_hal::spi::SpiDevice: chip select belongs to the SPI device, so the bus can be shared with other peripherals.
  • Blocking driver (blocking::St7305) and, with the async feature, the same API on embedded-hal-async (asynch::St7305). Both can write a frame in two steps (begin_write_ram + write_ram_data); with the async driver, rendering the next frame can then overlap with the DMA transfer of the current one.
  • Typed register commands (cmd) with const fn encoders and compile-time checked defaults.
  • Wrappers for the common operations: reset, sleep_in / sleep_out, display_on / display_off, set_inversion, set_power_mode, set_tearing_effect, set_address_window, and release to get the SPI device and the pins back.
  • Optional framebuffer feature: a FrameBuffer implementing embedded-graphics DrawTarget<Color = BinaryColor>, with a pluggable pixel mapping and a runtime Rotation (0, 90, 180, 270 degrees).
  • Initialization tables are &[InitStep]: the parameters borrow, so a table can be built at run time (from a config, a buffer) as well as be a const.
  • Errors keep the SPI and pin error types and implement Display and core::error::Error.
  • Optional defmt feature: defmt::Format for the errors and the small public types.
  • Host tests (mock SPI, pins and delay) cover the command framing, both drivers, the initialization table and the frame buffer.

Not supported yet

The driver only writes. Reading from the controller (RDDID, RDDST, RDDPM, RDID1..3, RAMRD, RDMEMC, NVMRD) is not supported yet: the Instr table lists these opcodes, but there are no methods for them, and SpiDevice is used for writes only.

Features

Feature Enables
async asynch::St7305 on embedded-hal-async
framebuffer framebuffer::{FrameBuffer, Landscape, PixelMap, Rotation, Window}, the panel layout in presets::waveshare_rlcd_42, embedded-graphics-core integration
defmt defmt::Format for Error, PowerMode, InitStep, Rotation and Instr
nvm-program cmd::nvm commands that program the panel NVM and set its ID; they need an external programming supply (VPP, about 7.5 V) and EXTB pulled to VDGND (datasheet v0.2, §6.5)

No feature is enabled by default.

Minimum supported Rust version: 1.87 (rust-version in Cargo.toml).

Wiring

The driver needs three things from your HAL:

  • an embedded_hal::spi::SpiDevice (async: embedded_hal_async::spi::SpiDevice) in SPI mode 0, MSB first, with the chip select inside the device. The panel guarantees 33 MHz; the driver never configures the bus;
  • two OutputPins with the same error type: DC (data/command) and RST (reset);
  • a DelayNs for the reset and the initialization pauses.

A bus plus a chip select pin become a SpiDevice with embedded-hal-bus, for example ExclusiveDevice::new_no_delay(bus, cs).

Example: blocking

use embedded_graphics::{pixelcolor::BinaryColor, prelude::*, primitives::{Circle, PrimitiveStyle}};
use st7305_lcd::{
    blocking::St7305,
    presets::{WAVESHARE_RLCD_42, waveshare_rlcd_42::FrameBuffer},
};

// `spi` is an `embedded_hal::spi::SpiDevice`, `dc` and `rst` are `OutputPin`s.
let mut display = St7305::new(spi, dc, rst);
display.init_with(WAVESHARE_RLCD_42, &mut delay)?;
display.set_inversion(false)?;

// 400 x 300 pixels at 1 bit per pixel = 15 000 bytes, the layout of this panel.
let mut frame = FrameBuffer::new();
// Drawing into a `FrameBuffer` cannot fail (`Error = Infallible`).
let Ok(()) = Circle::new(Point::new(100, 50), 120)
    .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
    .draw(&mut frame);

display.write_ram(frame.as_bytes())?;

The error type of every method is st7305_lcd::Error<SpiError, PinError>.

Example: async, transfer overlapped with rendering

use embassy_futures::join::join;
use st7305_lcd::{
    asynch::St7305,
    presets::{WAVESHARE_RLCD_42, waveshare_rlcd_42::FrameBuffer},
};

let mut display = St7305::new(spi, dc, rst);
display.init_with(WAVESHARE_RLCD_42, &mut delay).await?;
display.set_inversion(false).await?;

// `front` is being sent, `back` is being rendered.
let (mut front, mut back) = (FrameBuffer::new(),
                             FrameBuffer::new());
loop {
    display.begin_write_ram().await?;
    // The driver future comes first, so the DMA transfer starts before the CPU work.
    let (sent, ()) = join(display.write_ram_data(front.as_bytes()), async {
        back.fill(false);
        render_next(&mut back);
    })
    .await;
    sent?;
    core::mem::swap(&mut front, &mut back);
}

write_ram_data must be called right after a successful begin_write_ram; this order is guaranteed only by documentation. The first .await of the joined driver future has to be the data transfer itself (not the 1-byte RAMWR command), otherwise the DMA does not start before rendering and the overlap is lost.

Rotation

FrameBuffer draws in a logical orientation. The bytes in as_bytes() always stay in the native panel layout and are sent as they are.

use st7305_lcd::{framebuffer::Rotation, presets::waveshare_rlcd_42::FrameBuffer};

let frame = FrameBuffer::new().with_rotation(Rotation::Deg90);
assert_eq!(frame.size(), Size::new(300, 400)); // `OriginDimensions` reports the logical size

Deg90 and Deg270 turn the picture clockwise by the given angle (the logical top-left corner goes to the native top-right corner for Deg90). Changing the rotation does not move the pixels already drawn: redraw the frame. read_row / write_row copy whole logical rows along a native row or column (also mirrored), which is much faster than pixel by pixel.

Partial updates

A small change does not need the whole frame on the wire. FrameBuffer::window turns a rectangle in logical coordinates (any Rotation) into a Window: the smallest GRAM window of the panel that contains it, grown to the panel grid (2 pixels horizontally, 12 vertically) and clipped to the panel. copy_window copies the bytes of that window, and write_window sends them.

use st7305_lcd::{framebuffer::Window, presets::waveshare_rlcd_42::Map};

// `area` is an `embedded_graphics::primitives::Rectangle` in logical coordinates;
// `scratch` is a `[u8; 15_000]` (a window never needs more).
if let Some(window) = frame.window(area) {
    let data = frame.copy_window(&window, &mut scratch);
    display.write_window(window.column_address_set(), window.row_address_set(), data)?;
}

// The window stays set: restore the whole GRAM window before the next full frame.
let full = Window::full::<Map>();
display.set_address_window(full.column_address_set(), full.row_address_set())?;

The driver does not track what changed: the application knows which regions it redrew (a clock, one cell of a calendar) and passes them in. Window knows the GRAM layout of the panel (CASET columns are 12-pixel bands counted from the top, RASET rows are pairs of pixel columns); it is verified on the Waveshare 4.2" panel, for other panels it is an assumption. Landscape<W, H, CASET0, RASET0> takes the first CASET column and RASET row of the panel's window; presets::waveshare_rlcd_42::Map has the values of the verified panel.

Sleep

sleep_in / sleep_out alone are enough; GRAM and the on/off state of the display are kept (datasheet v0.2, §8.1.6 and §8.1.7). The methods wait the pauses the datasheet requires (5 ms after SLPIN, 100 ms after SLPOUT).

display.sleep_in(&mut delay)?;
// ... later
display.sleep_out(&mut delay)?; // the previous picture is shown again

Measured on hardware

Waveshare ESP32-S3-RLCD-4.2, esp-hal 1.2.2, SPI at 80/3 MHz (about 26.7 MHz), 400x300 at 1 bpp (15 000 bytes per frame), Conway's Game of Life over the whole frame:

Mode Frame time FPS
blocking write_ram, then render about 9.0 ms about 111
begin_write_ram + join(write_ram_data, render) about 4.6 ms (bus-bound) about 218

A partial update with write_window is bus-bound too: a window of 1927 bytes on average (a 21x21 square moving over a static scene) took about 0.67 ms against 4.6 ms for the whole frame. Every window costs about 90 us for CASET, RASET and RAMWR, the rest is about 3.3 bytes per us.

Why another ST7305 crate?

Other ST7305 drivers already exist on crates.io. This table compares them with this crate so you can pick what fits your project. It reflects st7305 0.1.2 and st7305-driver 0.1.0 and may be out of date.

this crate st7305 st7305-driver
SPI abstraction SpiDevice (CS owned by the device) display-interface WriteOnlyDataCommand SpiBus + manual CS pin
Shares the SPI bus with other devices yes depends on the interface implementation no
Async full (async feature, SPI transfers awaited) delays only; flush and commands are blocking none
Frame transfer can overlap with rendering yes no no
Register commands typed, const fn encoders, compile-time asserts opcode enum + raw bytes opcode enum + raw bytes
Driver dependencies embedded-hal (+ embedded-hal-async) display-interface, embedded-hal embedded-hal
SPI error preserved yes no (DisplayError) yes
Display / core::error::Error for errors yes no yes
embedded-graphics framebuffer feature, generic pixel map graphics feature, fixed 400x300 no
Framebuffer separate from the driver, you own the buffers inside the driver (15 KB) none, you pass raw bytes
Orientations Rotation 0/90/180/270 at runtime Landscape and Portrait raw MadCtl
Sleep / power mode / TE / address window API wrappers for all four basic wide
Partial update (window RAMWR) Window + write_window, any rotation no raw commands only
Host tests yes (mock SPI, pins, delay) no no
Init preset presets::waveshare_rlcd_42, verified on hardware hardcoded in init hardcoded in init

If you only need a quick, blocking, batteries-included setup with embedded-graphics, st7305 may be simpler. If you want a thin blocking driver with a wide register API on a dedicated SPI bus, look at st7305-driver. Choose this crate if you want SpiDevice, async with DMA overlap, or typed commands.

Panel notes

  • The GRAM window for the 400x300 panel is CASET 0x12..=0x2A and RASET 0x00..=0xC7 (15 000 bytes); the limits in datasheet v0.2 (264x320) do not match this panel. A CASET column is a band of 12 pixels counted from the top, a RASET row is a pair of pixel columns.
  • With display inversion on, bit 1 is white; with it off, bit 1 is black.

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you shall be dual licensed as above, without any additional terms or conditions.