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 `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:

| 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` |

[`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.