Skip to content
melastmohicanPublic

About

E-Paper Display Serial Interface Framework. A no_std, embedded-hal 1.0 compatible Rust driver framework for Electronic Paper Displays (EPD).

Topics

Resources

Stars

10 stars

Watchers

0 watching

Forks

Latest commit

 

History

140 Commits

Folders and files

Repository files navigation

epdsi: E-Paper Display Serial Interface Framework

Crates.io Documentation CI License

A no_std, embedded-hal 1.0 compatible Rust driver framework for Electronic Paper Displays (EPD).

Panels driven by epdsi: GDEQ0426T82, GDEY037T03, ZJY122250, GDEM0213B74, GDEM0154Z90, E2417QS0A3, E2154QS0F1

Every panel above driven by epdsi on an RP2350 Pico 2, left to right: GDEQ0426T82 (SSD1677), GDEY037T03 (UC8253), ZJY122250 (JD79661), GDEM0213B74 (SSD1680), GDEM0154Z90 (SSD1681), E2417QS0A3 and E2154QS0F1 (Pervasive Spectra-4 BWRY, Drivers A and F).

Features

  • Modular Architecture: Decouples driver IC logic (EpdController) from physical panel specifications (EpdPanel).
  • embedded-hal 1.0 Compatible: Built around standard SpiDevice, OutputPin, InputPin, and DelayNs traits.
  • Low-RAM Paged Rendering: Built-in support for GxEPD2-style closure-based paged graphics rendering using tiny stack buffers.
  • embedded-graphics Integration: Implements DrawTarget and Dimensions via optional graphics feature (enabled by default).
  • Multi-Color Support: Unified handling for Monochrome, Tri-Color (Black/White/Red), Quad-Color (Black/White/Yellow/Red 2bpp), and 4bpp ACeP / E Ink Spectra palette displays.
  • Automatic RAM Alignment: Driver IC controllers automatically align panel widths to hardware byte boundaries (div_ceil(8) * 8).

Supported Controllers & Panels

Controller IC Supported Panels Resolution Color Mode Notes
SSD1681 (Ssd1681Controller / Ssd168xController) GDEM0154Z90 (GxEPD2_154_Z90c) 200 × 200 Tri-Color 1.54" Tri-Color SPI panel, Full refresh only (~14 s). Ssd168xRefreshMode::Partial is not usable; see note below. Partial window updates work via set_window at full-refresh speed
SSD1680(Z) (Ssd1680Controller / Ssd168xController) GDEM0213B74, GDEY0266Z90 (GxEPD2_266c), GDEY0266T90 (GxEPD2_266_GDEY0266T90) 122 × 250, 152 × 296, 152 × 296 Monochrome, Tri-Color GDEM0213B74: 2.13" Monochrome (Adafruit 6383), Full/FastFull/Partial refresh. GDEY0266Z90: Good Display GDEY0266Z90 / Waveshare 2.66" e-Paper Module (B), full refresh only (~18–20 s); see note below. Its Red RAM plane is inverted relative to the Black/White plane. GDEY0266T90: Good Display GDEY0266T90 / Waveshare 2.66" e-Paper, same footprint as GDEY0266Z90 but monochrome-only, and genuinely fast: real Full/Partial refresh, not the parity-only Partial the Tri-Color sibling has (FastFull also supported, not yet cleanly measured). Hardware-verified blocking on RP2350, RP2040 and ESP32-C3, and async on RP2350: Full and Partial both render correctly, though Partial measured ~4.1 s per update on the original RP2350 unit, not the ~500 ms the reference driver quotes. Also supports 4-level grayscale via GRAY4/Ssd168xRefreshMode::Gray4 (Adafruit_EPD-sourced, not Good Display/Waveshare), hardware-verified through epdsi's own driver across the same RP2350/RP2040/ESP32-C3 blocking + RP2350 async spread
SSD1685 (Ssd168xController with Ssd168xVariant::Ssd1685) GDEY0266T90H 184 × 360 Monochrome 2.66" high-resolution Monochrome Good Display GDEY0266T90H, Active-High BUSY. A different glass from GDEY0266T90: SSD1685 instead of SSD1680, so it needs the Ssd1685 variant (the wrong variant renders badly, it does not error). Full/FastFull/Partial refresh, measured 1191 ms Full and 356 ms Partial on the Feather RP2040, 1191 ms / 356 ms on the Pico 2 (1190 ms / 355 ms async), and 1193 ms / 356-357 ms on the XIAO ESP32-C3. Register sequence follows Good Display's demo and the SSD1685 datasheet. Hardware-verified blocking on RP2350, RP2040 and ESP32-C3, and async on RP2350 (Full and Partial; FastFull measured slower than Full, 1678 ms against 1191 ms)
JD79661 (Jd79661Controller / Jd7966xController) ZJY122250_0213AJH_E5 / GDEY0213F51 (GxEPD2_213c_GDEY0213F51) 122 × 250 Quad-Color 2.13" Quad-Color (Good Display GDEY0213F51, Seeed Studio 5779, Adafruit 6373, Active-Low BUSY)
JD79660A (Jd79660Controller / Jd7966xController) GDEM0154F51H (GxEPD2_154c_GDEM0154F51H) 200 × 200 Quad-Color 1.54" Quad-Color Good Display GDEM0154F51H / Waveshare 1.54inch e-Paper (G), SKU 30441, Active-Low BUSY, Full refresh only (~20 s). Shares its SPI register table with JD79661 (same Jd7966xController), differing only in which registers init writes. Hardware-verified blocking on RP2350, RP2040 and ESP32-C3, and async on RP2350; ~19.7 s measured full refresh on RP2040
JD79676A (Jd79676Controller / Jd7966xController) GDEY0213F52 122 × 250 Quad-Color 2.13" Quad-Color Good Display GDEY0213F52, Active-Low BUSY, full refresh only, no partial (~11 s per datasheet, 15.9 s measured on RP2040 with the default init). Opt-in Jd7966xRefreshMode::Fast sends the vendor demo's EPD_init_Fast and measured 10.9 s with clean colours on two units, but two of its commands are undocumented. Init follows Good Display's demo (0xE9 0x01, PON, rest from OTP). Hardware-verified blocking on RP2350, RP2040 and ESP32-C3, and async on RP2350
IST7163 (Ist7163Controller) GDEM037F51 240 × 416 Quad-Color 3.7" Quad-Color Good Display GDEM037F51 / Waveshare 3.7inch e-Paper (G), SKU 31065. Active-Low BUSY, full refresh only (about 20 s). A separate controller from the JD79661 family: PWR and BTST differ in length and the init runs two test-mode register banks. Fast init is not implemented, since its force-temperature value is outside the documented range
UC8253 (Uc8253Controller) GDEY037T03 (GxEPD2_370_GDEY037T03), SE0352N14TNGA0 240 × 416, 240 × 360 Monochrome, Tri-Color Both Active-Low BUSY. GDEY037T03: 3.7" Monochrome (Adafruit 6395), Full/FastFull/Partial/FastPartial refresh. SE0352N14TNGA0: Waveshare 3.52" e-Paper HAT (B), full refresh only (~16–20 s), needs Uc8253Variant::Se0352n14: the two panels disagree on init, RAM plane order and ink polarity
SSD1677 (Ssd1677Controller) GDEQ0426T82 (GxEPD2_426_GDEQ0426T82) 800 × 480 Monochrome 4.26" Monochrome (Seeed Studio 6398, SE8350/SSD1677), Full/FastFull/Partial refresh. Also supports 4-level grayscale via GRAY4/Ssd1677RefreshMode::Gray4Preclear+Gray4 (Adafruit_EPD-sourced, not Seeed/Good Display), hardware-verified on all four boards this crate targets: RP2350 blocking and async, ESP32-C3, and RP2040
ED2208 (Ed2208Controller) GDEP073E01 (GxEPD2_730c_GDEP073E01) 800 × 480 Spectra 6 (4bpp) 7.3" six-colour E Ink Spectra 6 / GDEP073E01(E6): black, white, red, yellow, blue, green. SevenColor::Orange is ACeP-7 only and not renderable here (Seeed reTerminal E1002)
Pervasive Displays (PervasiveBwController) E2266KS0C1 (EPD_266_KS_0C), E2290KS0F1 (EPD_290_KS_0F) 152 × 296, 168 × 384 Monochrome Pervasive Displays 2.66" (Driver C) & 2.90" (Driver F) Panels
Pervasive Displays BWRY (PervasiveBwryController) E2154QS0F1 (EPD_154_QS_0F), E2417QS0A3 (EPD_417_QS_0A) 152 × 152, 400 × 300 Quad-Color (Spectra-4) Pervasive Displays 1.54" (Driver F) & 4.2" (Driver A), OTP-sourced registers read via a bit-banged 3-wire handshake (epdsi::bus3::Spi3Bus), Active-Low BUSY

Hardware Note for EXT3-1 Extension Boards: Ensure the J3 jumper is OPEN ($10,\mu\text{H}$ inductor path) for panels $\le 3.7"$ (e.g. 2.66" and 2.9" panels). If J3 is closed ($47,\mu\text{H}$ path), the DC-DC booster chokes during current bursts, causing voltage sags and BUSY pin hangs.

Have a stock Waveshare module? Four of epdsi's panels are the exact glass Waveshare sells, just listed above under the Good Display part number: GDEY0266Z90, GDEY0266T90, GDEM0154F51H, and SE0352N14TNGA0 are all confirmed Waveshare-branded units. The same Good Display-designed glass often ships under more than one brand (Good Display, Waveshare, Adafruit, Seeed all resell overlapping part numbers), so check the part number printed on your module's FPC against the table above first. If yours isn't one of those four, it may still be covered by epd-waveshare instead.

Panel support policy

I can and will only support panels I have in hand. Register-level parity claims in this README are backed by hardware I've actually tested against vendor reference drivers. I won't add a panel from a datasheet alone.

I accept panel donations to add support, but only on exception. I only take on panels from known sources that are well documented. The same Good Display-designed glass often ships under several brands (Good Display itself, Waveshare, Adafruit, Seeed), and the Pervasive Displays panels I buy directly from Pervasive. If you have an interesting panel that fits that bar, open an issue. I buy new panels occasionally, but adding support takes as long as it takes.

Tri-Color panels and partial refresh

Colour panels have no fast/differential waveform. The red (or yellow) pigment is a heavier particle that needs the full OTP waveform to migrate, so every update on a Tri-Color or Quad-Color panel takes seconds: roughly 14 s on the GDEM0154Z90, 18–20 s on the GDEY0266Z90, and 16–20 s on the SE0352N14TNGA0, which for that reason exposes no partial mode at all (Uc8253RefreshMode is ignored under Uc8253Variant::Se0352n14).

Ssd168xRefreshMode::Partial drives UPDATE_DISPLAY_CTRL2 = 0xFC, selecting the controller's built-in fast LUT. That LUT only exists for monochrome panels. On a colour panel it is not a speed-up and actively breaks the image: the update runs at full-refresh speed anyway, and because the fast path only rewrites the Black/White RAM, all red content is dropped. Keep colour panels on Ssd168xRefreshMode::Full.

Ssd168xRefreshMode::FastFull (0xC7, preceded by a 0x5A temperature-register override that reloads the OTP LUT) does help, but how much depends on the glass rather than the controller. Measure it. On a GDEY0266Z90 it came out at 16.2 s against 20.0 s for Full, a 19 % saving, on DKE glass; Good Display quote only ~19 s against ~20 s for their own. Same IC, same resolution, different OTP waveform. No colour panel approaches the sub-second figures a monochrome SSD168x panel reaches, because the red pigment has no differential waveform to skip.

Ssd168xRefreshMode::BaseMap (0xF4) primes the controller's previous-frame buffer before a run of Partial updates. That is a monochrome-only workflow: on a colour panel 0x26 is always the colour plane, never a previous-frame buffer, so seeding it with a Black/White image (correct on the GDEM0213B74) sets nearly every bit and renders the region solid red. Both modes exist for parity with Good Display's reference driver; on a colour panel neither is faster than Full.

Region-limited updates still work on colour panels: narrow the RAM window with set_window/set_cursor, write both colour channels for that region, then refresh on the Full waveform. Only the windowed area is redrawn, but it costs a full refresh. This mirrors GxEPD2's GxEPD2_154_Z90c, where partial_refresh_time == full_refresh_time and hasFastPartialUpdate == false.

For genuine sub-second differential updates, use a monochrome panel: GDEM0213B74 (Ssd1680RefreshMode::Partial), GDEY037T03 (Uc8253RefreshMode::FastPartial), GDEQ0426T82 (Ssd1677RefreshMode::Partial), or the Pervasive Displays panels via PervasiveRefreshMode::Fast and write_fast_frame.

Drawing on Tri-Color panels

There are two supported ways to draw Tri-Color content, and PageBufferPair is the recommended default. One embedded-graphics pass, addressed by TriColor (White/Black/Accent), reaches both RAM planes at once, and PlanePolarity (SSD168X for GDEM0154Z90/GDEY0266Z90, UC8253 for SE0352N14TNGA0) carries each panel's ink-bit convention as a required constructor argument, so drawing code no longer needs a raw fill-byte or a deliberately-inverted BinaryColor at the call site to compensate for it. That class of bug is the single most common mistake porting one Tri-Color panel's example to another (see the ssd1680_gdey0266z90_epd example's "Note on ink polarity" for what it looks like when it's wrong); PageBufferPair removes it structurally rather than by convention. See examples 1, 7 and 10 below, and render_paged_tri_color for the paged (low-RAM) counterpart of a full-panel sweep.

The older manual dual-PageBuffer approach still works and remains fully supported. It is not deprecated, just no longer the default recommendation. Reach for it when you need a windowed region update (render_paged_tri_color only sweeps the whole panel; a manual set_window/set_cursor call plus two PageBuffers is still how to redraw just a status band), or when comparing refresh-mode timing the way the hardware examples in this repo do.

Both modes are demonstrated side by side, on the same content, on real hardware: every <controller>_<panel>_epd example in this repo's downstream hardware examples now has a <controller>_<panel>_tri_epd sibling that reproduces its exact phases and output through PageBufferPair instead: 12 examples across 4 boards (RP2350 blocking and async, RP2040, ESP32-C3) and all 3 Tri-Color panels epdsi ships, all flashed and confirmed working. If you're deciding which mode to learn first, start with the _tri_epd version of whichever example matches your panel.

Quick Start

Add epdsi to your Cargo.toml:

[dependencies]
epdsi = "0.8"
embedded-graphics = "0.8"

0.2.0 is a breaking release (EpdBusError gains variants and is now #[non_exhaustive], EpdController::Error requires From<ValidationError>, and the three EpdPanel methods deprecated since 0.1.6 are removed) alongside the new async API below; see the CHANGELOG for the full list before upgrading from 0.1.x.

Cargo features

Feature Default Description
blocking yes The plain embedded-hal 1.0 API used in every example below. Disabling it (default-features = false, then re-add graphics if wanted) switches every controller/bus/driver method to its embedded-hal-async counterpart instead: same types, same method names, same Results, just .awaited.
graphics yes Implements embedded-graphics-core's DrawTarget and Dimensions for PageBuffer, PageBufferPair and GrayBufferPair. Disable it to drop the embedded-graphics-core dependency; PageBuffer/PageBufferPair/GrayBufferPair and render_paged/render_paged_tri_color/render_paged_gray4 still work, you just draw into the buffer(s) yourself.
defmt no Derives defmt::Format on the public error and mode enums (EpdBusError, Spi3BusError, PervasiveBwryOtpError, ColorMode, ColorChannel, SevenColor, and the per-controller refresh/variant enums) for logging on embedded targets.

blocking and the async API it replaces are mutually exclusive, not additive: a bus is one or the other. See the crate root doc's "Cargo features" section for the two behavior differences worth knowing before switching (async busy-waits have no timeout; render_paged's drawing closure stays synchronous either way).

The minimum supported Rust version is 1.75.

The snippets below are abridged; for complete flashable programs see Examples on real hardware.

1. Usage Example (SSD1681 Controller + GDEM0154Z90 Panel)

GDEM0154Z90 is Tri-Color, so drawing goes through PageBufferPair and TriColor rather than a single PageBuffer: one drawing pass addresses both RAM planes, and PlanePolarity::SSD168X carries the panel's ink-bit convention instead of a raw fill-byte comment at every call site.

use epdsi::prelude::*;
use embedded_graphics::{prelude::*, primitives::{Rectangle, PrimitiveStyle}, geometry::{Point, Size}};

// Initialize SPI bus wrapper and controller
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Ssd1681Controller::new(GDEM0154Z90::WIDTH, GDEM0154Z90::HEIGHT);

// Build driver orchestrator
let mut epd = EpdBuilder::<_, GDEM0154Z90>::new(controller).build(epd_bus);

// Initialize display
epd.init(&mut delay).unwrap();

// Render graphics using PageBufferPair: both RAM planes together, addressed by TriColor
let mut bw_buf = [0xFFu8; (200 * 200 / 8) as usize];
let mut accent_buf = [0x00u8; (200 * 200 / 8) as usize];
let mut display = PageBufferPair::new(
    &mut bw_buf, &mut accent_buf, 200, 200, 0, PlanePolarity::SSD168X,
);

Rectangle::new(Point::new(10, 10), Size::new(50, 50))
    .into_styled(PrimitiveStyle::with_fill(TriColor::Black))
    .draw(&mut display)
    .unwrap();

// Send both frames and refresh display
epd.write_frame(ColorChannel::BlackWhite, display.bw().as_slice()).unwrap();
epd.write_frame(ColorChannel::RedYellow, display.accent().as_slice()).unwrap();
epd.refresh(&mut delay).unwrap();

2. Usage Example (JD79661 Controller + 2.13" Quad-Color Panel)

use epdsi::prelude::*;

// Initialize SPI bus wrapper and JD79661 controller
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Jd79661Controller::new(ZJY122250_0213AJH_E5::WIDTH, ZJY122250_0213AJH_E5::HEIGHT);

// Build driver for Adafruit 6373 Quad-Color 122x250 display
let mut epd = EpdBuilder::<_, ZJY122250_0213AJH_E5>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// Send 2bpp packed QuadColor frame buffer (8,000 bytes for 128x250 hardware RAM)
epd.write_frame(ColorChannel::BlackWhite, &quad_color_frame_buf).unwrap();
epd.refresh(&mut delay).unwrap();

3. Usage Example (PervasiveBwController + E2266KS0C1 Panel)

use epdsi::prelude::*;

// Initialize SPI bus wrapper and Pervasive Displays controller
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = PervasiveBwController::new(E2266KS0C1::WIDTH, E2266KS0C1::HEIGHT);

// Build driver for Pervasive Displays 2.66" 152x296 Monochrome panel
let mut epd = EpdBuilder::<_, E2266KS0C1>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// Clear RAM channel and refresh
epd.clear_frame(ColorChannel::BlackWhite, 0xFF).unwrap();
epd.refresh(&mut delay).unwrap();
epd.sleep(&mut delay).unwrap();

4. Usage Example (ED2208 Controller + GDEP073E01 Spectra 6 Panel)

use epdsi::prelude::*;

// Initialize SPI bus wrapper and ED2208 controller
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Ed2208Controller::new(GDEP073E01::WIDTH, GDEP073E01::HEIGHT);

// Build driver for 7.3" 800x480 Spectra 6 EPD display (e.g. Seeed reTerminal E1002)
let mut epd = EpdBuilder::<_, GDEP073E01>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// Clear display frame buffer (fill with White, 0x11)
epd.clear_frame(ColorChannel::Color7(0), SevenColor::pack(SevenColor::White, SevenColor::White)).unwrap();

// Send 4bpp packed 7-color frame buffer (192,000 bytes for 800x480)
epd.write_frame(ColorChannel::Color7(0), &seven_color_frame_buf).unwrap();
epd.refresh(&mut delay).unwrap();

5. Usage Example (Ssd1680Controller + GDEM0213B74 Panel)

use epdsi::prelude::*;

// Initialize SPI bus wrapper and SSD1680(Z) controller
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Ssd1680Controller::new(GDEM0213B74::WIDTH, GDEM0213B74::HEIGHT)
    .with_refresh_mode(Ssd1680RefreshMode::Full);

// Build driver for Adafruit 6383 2.13" Monochrome display
let mut epd = EpdBuilder::<_, GDEM0213B74>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();
epd.clear_frame(ColorChannel::BlackWhite, 0xFF).unwrap();
epd.refresh(&mut delay).unwrap();
epd.sleep(&mut delay).unwrap();

6. Usage Example (Uc8253Controller + GDEY037T03 Panel)

use epdsi::prelude::*;

// Initialize SPI bus wrapper and UC8253 controller (note: this panel's BUSY pin is active-low)
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Uc8253Controller::new(GDEY037T03::WIDTH, GDEY037T03::HEIGHT)
    .with_refresh_mode(Uc8253RefreshMode::FastFull);

// Build driver for Adafruit 6395 3.7" Monochrome display
let mut epd = EpdBuilder::<_, GDEY037T03>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();
epd.clear_frame(ColorChannel::BlackWhite, 0xFF).unwrap();
epd.refresh(&mut delay).unwrap();
epd.sleep(&mut delay).unwrap();

7. Usage Example (Uc8253Controller + SE0352N14TNGA0 Panel)

use epdsi::prelude::*;

// Same UC8253 IC as the GDEY037T03 above, but a different register profile: the variant is
// not optional here. The default profile's init, RAM plane order and CDI value all differ,
// and picking it renders inverted or blank rather than erroring.
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Uc8253Controller::new(SE0352N14TNGA0::WIDTH, SE0352N14TNGA0::HEIGHT)
    .with_variant(Uc8253Variant::Se0352n14);

// Build driver for the Waveshare 3.52" e-Paper HAT (B), 240x360 Tri-Color
let mut epd = EpdBuilder::<_, SE0352N14TNGA0>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// PlanePolarity::UC8253: unlike SSD168x, BOTH planes are inverted here (set bits are ink,
// the opposite of the monochrome UC8253 panel's 0xFF-white convention). PageBufferPair
// derives each plane's background fill from this, so no raw fill-byte juggling is needed.
let mut bw_buf = [0u8; (240 * 360 / 8) as usize];
let mut accent_buf = [0u8; (240 * 360 / 8) as usize];
let mut display = PageBufferPair::new(
    &mut bw_buf, &mut accent_buf, 240, 360, 0, PlanePolarity::UC8253,
);
// ... draw with TriColor::{Black, White, Accent} as in example 1 ...

epd.write_frame(ColorChannel::BlackWhite, display.bw().as_slice()).unwrap();
epd.write_frame(ColorChannel::RedYellow, display.accent().as_slice()).unwrap();
epd.refresh(&mut delay).unwrap();

// sleep() enters deep sleep; call init() again before the next frame.
epd.sleep(&mut delay).unwrap();

8. Usage Example (Ssd1677Controller + GDEQ0426T82 Panel)

use epdsi::prelude::*;

// Initialize SPI bus wrapper and SSD1677 controller
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Ssd1677Controller::new(GDEQ0426T82::WIDTH, GDEQ0426T82::HEIGHT);

// Build driver for Seeed Studio 6398 4.26" Monochrome display
let mut epd = EpdBuilder::<_, GDEQ0426T82>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();
epd.clear_frame(ColorChannel::BlackWhite, 0xFF).unwrap();
epd.refresh(&mut delay).unwrap();
epd.sleep(&mut delay).unwrap();

9. Usage Example (PervasiveBwryController + E2154QS0F1 Panel)

use epdsi::prelude::*;

// The BWRY OTP register read is a bit-banged 3-wire handshake (SCK + a single bidirectional
// DATA line), NOT the hardware SPI peripheral: the panel drives its response back on MOSI, and
// MISO is never used. `sck`/`mosi` must start as plain GPIO here (not SPI-function-bound) so
// `read_otp` can flip `mosi`'s direction; `mosi` must implement `epdsi::bus3::DynamicPin`.
let mut controller = PervasiveBwryController::new(E2154QS0F1::WIDTH, E2154QS0F1::HEIGHT)
    .with_variant(PervasiveBwryVariant::DriverF)
    .with_temperature(25);
let mut bus3 = Spi3Bus::new(cs_pin, sck_pin, mosi_pin, dc_pin, rst_pin, busy_pin);
controller.read_otp(&mut bus3, &mut delay).unwrap();
let (cs_pin, sck_pin, mosi_pin, dc_pin, rst_pin, busy_pin) = bus3.release();

// Reconfigure sck_pin/mosi_pin into the hardware SPI peripheral's function, build the SPI
// device, then wrap it with the normal 4-wire SpiBusWrapper for everything else.
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let mut epd = EpdBuilder::<_, E2154QS0F1>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// Send 2bpp packed BWRY frame buffer (5,776 bytes for the 152x152 panel)
epd.write_frame(ColorChannel::BlackWhite, &bwry_frame_buf).unwrap();
epd.refresh(&mut delay).unwrap();
epd.sleep(&mut delay).unwrap();

10. Usage Example (Ssd1680Controller + GDEY0266Z90 Panel)

use epdsi::prelude::*;

// Same SSD1680 profile as the monochrome GDEM0213B74 above; no variant selection needed.
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Ssd1680Controller::new(GDEY0266Z90::WIDTH, GDEY0266Z90::HEIGHT)
    .with_refresh_mode(Ssd168xRefreshMode::Full);

// Build driver for the Good Display GDEY0266Z90 / Waveshare 2.66" (B), 152x296 Tri-Color
let mut epd = EpdBuilder::<_, GDEY0266Z90>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// The two RAM planes disagree on ink polarity: 0xFF is white in the Black/White plane, but
// the Red plane is inverted (a set bit is red). PlanePolarity::SSD168X carries that, so
// PageBufferPair derives each plane's background fill correctly rather than a manual
// per-plane clear_frame call with an easy-to-invert raw byte.
let mut bw_buf = [0u8; (152 * 296 / 8) as usize];
let mut accent_buf = [0u8; (152 * 296 / 8) as usize];
let mut display = PageBufferPair::new(
    &mut bw_buf, &mut accent_buf, 152, 296, 0, PlanePolarity::SSD168X,
);
// ... draw with TriColor::{Black, White, Accent} as in example 1 ...

epd.write_frame(ColorChannel::BlackWhite, display.bw().as_slice()).unwrap();
epd.write_frame(ColorChannel::RedYellow, display.accent().as_slice()).unwrap();
epd.refresh(&mut delay).unwrap();

// sleep() enters deep sleep; call init() again before the next frame.
epd.sleep(&mut delay).unwrap();

11. Usage Example (Ssd1680Controller + GDEY0266T90 Panel)

use epdsi::prelude::*;

// Same SSD1680 profile as GDEM0213B74 and GDEY0266Z90 above; no variant selection needed.
// Unlike the Tri-Color GDEY0266Z90, this is a monochrome-only glass: a single PageBuffer, no
// accent plane, no ink-polarity gymnastics.
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Ssd1680Controller::new(GDEY0266T90::WIDTH, GDEY0266T90::HEIGHT)
    .with_refresh_mode(Ssd1680RefreshMode::Full);

// Build driver for the Good Display GDEY0266T90 / Waveshare 2.66" e-Paper, 152x296 Monochrome
let mut epd = EpdBuilder::<_, GDEY0266T90>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();
epd.clear_frame(ColorChannel::BlackWhite, 0xFF).unwrap();
epd.refresh(&mut delay).unwrap();

// Genuinely fast on this panel: unlike the Tri-Color sibling, Partial is a real differential
// mode here (~500 ms per the GxEPD2 reference), not a parity-only no-op.
epd.controller_mut().set_refresh_mode(Ssd1680RefreshMode::Partial);

epd.sleep(&mut delay).unwrap();

4-level grayscale (Gray4) on GDEY0266T90

GDEY0266T90 can also drive 4 distinguishable gray levels instead of plain 1-bit monochrome, via a register bundle not from Good Display/Waveshare (Waveshare's own spec lists 2 grayscale levels). It is transcribed verbatim from Adafruit_EPD's ThinkInk_266_Grayscale4_MFGN reference driver, and confirmed rendering four distinct gray bands through epdsi's own from-scratch, init-once port too. Hardware-verified blocking on RP2350, RP2040 and ESP32-C3, and async on RP2350; see GDEY0266T90's doc for the full provenance note.

use epdsi::prelude::*;

let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Ssd1680Controller::for_panel::<GDEY0266T90>()
    .with_gray4(GDEY0266T90::GRAY4)
    .with_refresh_mode(Ssd168xRefreshMode::Gray4);
let mut epd = EpdBuilder::<_, GDEY0266T90>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

let mut plane_a = [0u8; (152 / 8) * 8];
let mut plane_b = [0u8; (152 / 8) * 8];
render_paged_gray4(
    &mut epd,
    &mut delay,
    (&mut plane_a, &mut plane_b),
    Gray4Polarity::ADAFRUIT_SSD1680,
    8,
    |page_buf| {
        page_buf.set_pixel(10, page_buf.plane_a().y_offset() + 2, Gray4Color::Dark);
    },
)
.unwrap();

4-level grayscale (Gray4) on GDEQ0426T82

GDEQ0426T82 also supports 4-level grayscale, via a register bundle not from Seeed/Good Display, transcribed verbatim from Adafruit_EPD's ThinkInk_426_Grayscale4_GDEQ reference driver. Unlike GDEY0266T90's single-pass Gray4 above, Adafruit_SSD1677::update()'s grayscale branch is two-pass: a full refresh with the OTP LUT (Red/Yellow plane bypassed) sets a known monochrome baseline, then the custom LUT and voltage registers are reloaded, then a second refresh with the real Black/White (LSB) and Red/Yellow (MSB) planes. This needs Ssd1677Controller's own Gray4Preclear/Gray4 refresh-mode pair and reload_gray4_lut, not the generic render_paged_gray4. Hardware-verified rendering four distinct gray levels on all four boards this crate targets; see GDEQ0426T82's doc for the full provenance note.

use epdsi::prelude::*;

let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Ssd1677Controller::for_panel::<GDEQ0426T82>()
    .with_gray4(GDEQ0426T82::GRAY4)
    .with_refresh_mode(Ssd1677RefreshMode::Gray4Preclear);
let mut epd = EpdBuilder::<_, GDEQ0426T82>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// Pass 1: preclear. The same Black/White (LSB) content goes to both channels.
epd.write_frame(ColorChannel::BlackWhite, &plane_a_buf).unwrap();
epd.write_frame(ColorChannel::RedYellow, &plane_a_buf).unwrap();
epd.refresh(&mut delay).unwrap();

// The preclear refresh's OTP LUT load overwrote the custom LUT/voltage registers uploaded
// during init; reload them before the real Gray4 refresh.
let (bus, controller) = epd.split_mut();
controller.reload_gray4_lut(bus).unwrap();
epd.controller_mut().set_refresh_mode(Ssd1677RefreshMode::Gray4);

// Pass 2: the real image, Black/White (LSB) and Red/Yellow (MSB) planes.
epd.write_frame(ColorChannel::BlackWhite, &plane_a_buf).unwrap();
epd.write_frame(ColorChannel::RedYellow, &plane_b_buf).unwrap();
epd.refresh(&mut delay).unwrap();

12. Usage Example (Jd79660Controller + GDEM0154F51H Panel)

use epdsi::prelude::*;

// Jd79660Controller and Jd79661Controller are thin wrappers over the shared Jd7966xController,
// same relationship as Ssd1680Controller/Ssd1681Controller over Ssd168xController.
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Jd79660Controller::new(GDEM0154F51H::WIDTH, GDEM0154F51H::HEIGHT);

// Build driver for the Good Display GDEM0154F51H / Waveshare 1.54inch e-Paper (G), 200x200
let mut epd = EpdBuilder::<_, GDEM0154F51H>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// Send 2bpp packed QuadColor frame buffer (10,000 bytes; 200x200 needs no RAM padding)
epd.write_frame(ColorChannel::BlackWhite, &quad_color_frame_buf).unwrap();
epd.refresh(&mut delay).unwrap();

13. Usage Example (Jd79676Controller + GDEY0213F52 Panel)

use epdsi::prelude::*;

// Jd79676Controller wraps the same Jd7966xController as Jd79660Controller and Jd79661Controller.
// Its init is only `0xE9 0x01` and `PON`; panel, power, booster and resolution come from OTP.
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Jd79676Controller::new(GDEY0213F52::WIDTH, GDEY0213F52::HEIGHT);

// Build driver for the Good Display GDEY0213F52, 122x250. The rear sticker says F52. The
// GDEY0213F51 has the same flex ribbon stamp but a JD79661, so it needs Jd79661Controller.
let mut epd = EpdBuilder::<_, GDEY0213F52>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// Send 2bpp packed QuadColor frame buffer: 128 px RAM width * 2 bits / 8 * 250 rows = 8,000 bytes
epd.write_frame(ColorChannel::BlackWhite, &quad_color_frame_buf).unwrap();
epd.refresh(&mut delay).unwrap(); // full refresh only, about 11 s per the datasheet
epd.sleep(&mut delay).unwrap();

14. Usage Example (Ist7163Controller + GDEM037F51 Panel)

use epdsi::prelude::*;

// Ist7163Controller is its own controller, not a Jd7966xVariant: PWR and BTST differ in length
// from the JD79661 family and the init runs two test-mode register banks.
let epd_bus = SpiBusWrapper::new(spi_device, dc_pin, rst_pin, busy_pin);
let controller = Ist7163Controller::new(GDEM037F51::WIDTH, GDEM037F51::HEIGHT);

// Build driver for the Good Display GDEM037F51 / Waveshare 3.7inch e-Paper (G), 240x416
let mut epd = EpdBuilder::<_, GDEM037F51>::new(controller).build(epd_bus);

epd.init(&mut delay).unwrap();

// Send 2bpp packed QuadColor frame buffer: 240 px * 2 bits / 8 * 416 rows = 24,960 bytes
epd.write_frame(ColorChannel::BlackWhite, &quad_color_frame_buf).unwrap();
epd.refresh(&mut delay).unwrap(); // full refresh only, about 20 s
epd.sleep(&mut delay).unwrap();

Writing your own panel or controller

If the panel or driver IC you have is not in the table above, you can add it from your own crate without waiting for a release. EpdPanel and EpdController are public traits, so your types plug into EpdBuilder like the shipped ones:

  • A new panel on a supported IC: implement EpdPanel (width, height, color mode, optional VCOM, LUT and gate voltage) and reuse the shipped controller.
  • A driver IC epdsi does not support: implement EpdController (seven methods) and EpdPanel.
  • A near-sibling of a shipped IC: hold the shipped controller in a wrapper, override only the methods whose bytes differ, and delegate the rest. The crate docs have a worked example.

The one thing that trips people up is that EpdController is sync or async depending on epdsi's blocking feature. Your crate needs a feature named exactly blocking that forwards to it:

[dependencies]
epdsi = { version = "0.8", default-features = false }
embedded-hal = "1.0"
embedded-hal-async = "1.0"
maybe-async-cfg = "0.2"

[features]
default = ["blocking"]
blocking = ["epdsi/blocking"]   # the name must be `blocking`

The full walkthrough, a complete controller that compiles in both modes and a checklist to run before powering the panel are in the crate documentation. Before you open an issue asking for a new panel, read the panel support policy. In short, I only add panels I have in hand, and never from a datasheet alone. I buy new panels occasionally, if they are available to buy, and I accept donations only on exception, for panels from known sources that are well documented. Adding support takes as long as it takes.

If your panel fits that, open an issue with the part number, the vendor demo and the datasheet. Your own crate keeps working in the meantime.

Examples on real hardware

The snippets above are rust,ignore because they need real SPI and GPIO. For complete, flashable programs covering every supported controller, see, grouped by API:

Blocking (blocking feature, on by default):

Async (default-features = false, features = ["graphics"]):

Every panel below has been flashed and hardware-verified on every host listed for it, blocking and async producing the same on-panel result from the same driver code:

Controller Panel Verified blocking Verified async
Ssd1681Controller GDEM0154Z90: 1.54" Tri-Color RP2350, RP2040, ESP32-C3 RP2350
Ssd1680Controller GDEM0213B74: 2.13" Mono RP2350, RP2040, ESP32-C3 RP2350
Ssd1680Controller GDEY0266Z90: 2.66" Tri-Color RP2350, RP2040, ESP32-C3 RP2350
Ssd1680Controller GDEY0266T90: 2.66" Mono RP2350, RP2040, ESP32-C3 RP2350
Ssd1680Controller GDEY0266T90: 2.66" Gray4 RP2350, RP2040, ESP32-C3 RP2350
Jd79661Controller ZJY122250_0213AJH_E5: 2.13" Quad-Color RP2350, RP2040, ESP32-C3 RP2350
Jd79676Controller GDEY0213F52: 2.13" Quad-Color RP2350, RP2040, ESP32-C3 RP2350
Ist7163Controller GDEM037F51: 3.7" Quad-Color RP2350, RP2040, ESP32-C3 RP2350
Uc8253Controller GDEY037T03: 3.7" Mono RP2350, RP2040, ESP32-C3 RP2350
Uc8253Controller (Uc8253Variant::Se0352n14) SE0352N14TNGA0: 3.52" Tri-Color RP2350, RP2040, ESP32-C3 RP2350
Ssd1677Controller GDEQ0426T82: 4.26" Mono RP2350, RP2040, ESP32-C3 RP2350
Ssd1677Controller GDEQ0426T82: 4.26" Gray4 RP2350, RP2040, ESP32-C3 RP2350
PervasiveBwController (Driver C) E2266KS0C1: 2.66" Mono RP2350 RP2350
PervasiveBwController (Driver F) E2290KS0F1: 2.90" Mono RP2350 RP2350
PervasiveBwryController (Driver F) E2154QS0F1: 1.54" Spectra-4 RP2350 RP2350
PervasiveBwryController (Driver A) E2417QS0A3: 4.2" Spectra-4 RP2350 RP2350
Ed2208Controller GDEP073E01: 7.3" Spectra 6 N/A ESP32-S3

Every supported controller has a working example, verified on hardware, in both APIs where the panel's example has an async counterpart. The Ed2208Controller/GDEP073E01 examples ship async-only in rust-reterminal-e1002-examples: that repo already runs an Embassy executor, so its examples were converted in place rather than kept as a separate blocking pair.

The Pervasive Displays panels (E2266KS0C1, E2290KS0F1, E2154QS0F1, E2417QS0A3) need the EXT3-1 extension board and are RP2350-only for now; the other seven panels are exercised across three blocking hosts and two async hosts, keeping everything above main() byte-identical to the RP2350 originals so that only board bring-up differs. That is the point of the EpdPanel / EpdController / SpiBusWrapper split: the same driver code runs unchanged across Cortex-M0+, Cortex-M33, RISC-V and Xtensa, under two HAL families and both blocking and async executors.

The GDEM0213B74 on an SSD1680 gives a 3894 ms full refresh and 1018 ms differential partial on RP2350, and 3893 / 1017 ms on a Feather RP2040: the same numbers to within a millisecond, from identical driver code on two MCU families. Running one diagnostic across hosts is also how a failing board gets identified rather than mistaken for a driver defect; see Troubleshooting.

Troubleshooting on real hardware

Every controller here is register-level parity with a vendor reference driver, and the tests assert exact SPI byte streams. So when a panel misbehaves, the register sequence is rarely the cause. Across this project's bring-ups it has almost always been one of four things, worth working in this order:

  1. Panel state: the panel is not in the condition you think it is.
  2. Identity: the panel is not the panel you think it is.
  3. The board: timing and power differ between MCUs even though the driver code does not.
  4. The glass: the waveform lives in the panel, and varies by supplier and batch.

0. Power-cycle before you debug anything

E-paper retains its last write, and the controller can be left latched busy by an interrupted run or a hot-swapped FPC. The next run then hits busy timeouts and looks broken: shifted content, refreshes returning instantly, refreshes that appear to hang. hard_reset does not clear it; only removing power does. Connect and disconnect FPCs with the board unpowered.

Time your refreshes. It is the cheapest diagnostic there is, and it distinguishes "slow" from "never ran": a tri-colour panel physically cannot update in 100 ms, so a refresh that returns that fast did not drive the display. Equally, a refresh reported as 100 ms when the panel demonstrably takes 3.9 s means your instrument is broken, not the driver.

Reason from a clean run only. Never from an interrupted one, or from any run after one.

1. Is it the panel you think it is?

The same glass ships behind different controllers. DKE's 2.66" family is the cautionary case: all 152 × 296, all 24-pin, visually identical:

Part number Driver IC
DEPG0266RWS800F34HP SSD1680: works with Ssd1680Controller
DEPG0266RWF51BF1 JD79651B, not supported by that controller
DEPG0266RWU25DF15 UC8251d, not supported by that controller

Read the label before assuming a driver fault. CursedHardware/epd-datasheet maps part numbers to driver ICs for most vendors, and grepping a part-number stem reveals the vendor's own encoding by comparison.

The second identity trap is internal: EpdController never sees the PANEL type, so a controller/panel variant mismatch cannot be caught at compile time. Ssd168xVariant, Uc8253Variant, PervasiveDriverVariant and PervasiveBwryVariant all render inverted or blank rather than returning an error. If a panel needs a non-default variant, its module docs say so.

2. Symptom lookup

Symptom Likely cause
Nothing at all; BUSY never releases Wiring or power. Check busy_active_high matches the panel; several panels here are active-low. On EXT3-1 boards with panels ≤ 3.7", the J3 jumper must be OPEN
Refresh returns in milliseconds Controller latched busy from an earlier interrupted run; power-cycle (see above)
Whole panel comes up black, or stays white Wrong clear_frame fill byte for that plane's ink polarity
Colour panel: a region or the whole screen is solid red/yellow Colour-plane polarity. On SSD168x tri-colour, 0x24 wants 0xFF for white but 0x26 wants 0x00 for no colour; the planes disagree
Image mirrored or sheared WIDTH/HEIGHT transposed. WIDTH is the short axis; vendors advertise the landscape figure
Image correct but rotated 180° Mounting or connector orientation, not a driver fault; use DisplayRotation::Rotate180
Content shifted a few pixels per row Row stride. A width that is not a byte multiple still occupies width.div_ceil(8) bytes
Colour vanishes on a partial update Partial on a colour panel: the fast LUT is monochrome-only and drops the colour plane
Correct geometry, weak colour or ghosting The glass's waveform, not the registers; see §4

3. A different microcontroller

The same driver code runs across Cortex-M0+, Cortex-M33, RISC-V and Xtensa. When a panel works on one board and not another, the difference has consistently been timing, power, or the board itself, not logic. Real, resolved examples from this project, all found on one XIAO ESP32-C3 and none of which reproduced on RP2350:

  • The controller dropped its charge pump after an update, so the next bare refresh was silently ignored: BUSY never asserted, the poll read idle, and refresh returned in 0 ms having drawn nothing. Fixed by issuing POWER_ON before every refresh.
  • BUSY was not asserted instantly, and no fixed settling delay could be tuned to cover it: 10 ms held on some refreshes and missed others in the same run. Fixed by waiting for the BUSY edge (SpiBusWrapper::wait_busy_assert), bounded so a missing panel still reads idle rather than hanging.
  • The reset pulse was 2 ms, matching the vendor driver, and latched only intermittently. Now 30 ms.

That module was later found to be faulty, by substitution: a different MCU ran clean on the same carrier, cable and panel. All three fixes above are kept regardless, each independently justified by a vendor reference and not only by those symptoms. The misdiagnosis is the lesson: symptoms chased on a single board can be the board, not the platform. A replacement XIAO ESP32-C3 module has since run every non-Pervasive panel (GDEM0154Z90, GDEM0213B74, GDEY0266Z90, ZJY122250_0213AJH_E5, GDEY037T03, SE0352N14TNGA0, GDEQ0426T82) cleanly, so treat the ESP32-C3 itself as a fully supported host, not a risk: the fault was one unit.

So the method matters more than the list above: run one identical diagnostic on a second host. If the code is the same and only the host differs, a discrepancy localises to the host. Worked example from that bring-up, all on one 2.13" panel and one driver build, kept as a record of what a faulty host actually looks like in the data rather than in a hunch:

Host Full refresh Differential partial
RP2350 3894 ms 1018 ms
Feather RP2040 3893 ms 1017 ms
ESP32-C3, healthy ~3891 ms ~1017 ms
ESP32-C3, faulty module (since replaced) 7450 ms 98 ms

Two healthy hosts agreeing to a millisecond, and one host deviating in both directions at once, is not a driver result. Note the shape of the bad row: too slow and too fast. A uniform stretch would suggest a slow clock; deviation in both directions says the waveform is not executing as specified, which is electrical.

So: suspect supply decoupling, cables and connectors, SPI clock, reset timing, and the board itself, before suspecting registers.

4. A different panel batch or glass vendor

Waveshare, Good Display and DKE sell the same panel, and a module may ship with glass from any of them. The GDEY0266Z90 supported here was brought up on DKE glass stamped DEPG0266RWS800F34HP. Electrically that is fine, and it is checkable rather than assumed: GxEPD2's DKE driver (GxEPD2_266_BN) and its Good Display driver (GxEPD2_266c) have identical init register sets.

What does differ is the OTP waveform, which lives in the panel and is not selected by any code here. Measured on a GDEY0266Z90, Ssd168xRefreshMode::FastFull took 16.2 s against 20.0 s for Full (a real 19 % saving), where Good Display quote only ~19 s against ~20 s on their own glass. Same IC, same resolution, different glass.

Two consequences worth internalising:

  • Every timing figure in these docs is a reference point, not a guarantee. Measure on the panel in front of you. The ssd1680_gdey0266z90_epd example logs its own refresh durations for exactly this reason.
  • Weak or pink colour, ghosting, uneven ink density and a different refresh duration are waveform symptoms, not register faults. No amount of register auditing fixes them, and nothing in epdsi can select a different waveform.

Debugging discipline

Distilled from the bring-ups behind this crate, in rough order of how much time each has saved:

  • Suspect hardware and panel state before software. Running stock Arduino GxEPD2 across a couple of boards has twice found in one experiment what hours of driver analysis did not.
  • Validate any new diagnostic against a known measurement before trusting what it tells you.
  • Watch the panel, not the log. A hand-rolled trigger sequence once reported entirely plausible timings while never driving the display at all.
  • Change one thing at a time, and power-cycle between attempts.
  • Reproduce on a second host before concluding anything about the driver; see §3.

License

Licensed under either of:

at your option.

About

E-Paper Display Serial Interface Framework. A no_std, embedded-hal 1.0 compatible Rust driver framework for Electronic Paper Displays (EPD).

Topics

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages