# 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
| `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 `OutputPin`s 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`](https://crates.io/crates/embedded-hal-bus), for example
`ExclusiveDevice::new_no_delay(bus, cs)`.
## Example: blocking
```rust,ignore
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
```rust,ignore
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.
```rust,ignore
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.
```rust,ignore
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`).
```rust,ignore
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:
| 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.
| 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` |
[`st7305`]: https://crates.io/crates/st7305
[`st7305-driver`]: https://crates.io/crates/st7305-driver
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
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
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.