A no_std, embedded-hal 1.0 compatible Rust driver framework for Electronic Paper Displays (EPD).
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).
- Modular Architecture: Decouples driver IC logic (
EpdController) from physical panel specifications (EpdPanel). embedded-hal1.0 Compatible: Built around standardSpiDevice,OutputPin,InputPin, andDelayNstraits.- Low-RAM Paged Rendering: Built-in support for GxEPD2-style closure-based paged graphics rendering using tiny stack buffers.
embedded-graphicsIntegration: ImplementsDrawTargetandDimensionsvia optionalgraphicsfeature (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).
| 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, andSE0352N14TNGA0are 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 byepd-waveshareinstead.
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.
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.
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.
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.
| 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.
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();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();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();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();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();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();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();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();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();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();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();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();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();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();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();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();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
epdsidoes not support: implementEpdController(seven methods) andEpdPanel. - 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.
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):
rust-rpico2-discovery: RP2350 Pico 2,rp-hal(Cortex-M33)adafruit-feather-thinkink-discovery: Adafruit Feather RP2040 ThinkInk,rp-halvia BSP (Cortex-M0+). Panels seat directly in the board's 24-pin FPC socket, so there is no carrier or jumper wiringxiao-esp32c3-blinky: Seeed XIAO ESP32-C3 on the ePaper Driver Board for XIAO,esp-hal(RISC-V)
Async (default-features = false, features = ["graphics"]):
rust-rpico2-embassy-examples: RP2350 Pico 2, Embassy +embassy-rp(Cortex-M33), same board asrust-rpico2-discovery, async twins of every example thererust-reterminal-e1002-examples: Seeed reTerminal E1002 (XIAO ESP32-S3), Embassy +esp-hal(Xtensa)
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.
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:
- Panel state: the panel is not in the condition you think it is.
- Identity: the panel is not the panel you think it is.
- The board: timing and power differ between MCUs even though the driver code does not.
- The glass: the waveform lives in the panel, and varies by supplier and batch.
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.
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.
| 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 |
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
refreshreturned in 0 ms having drawn nothing. Fixed by issuingPOWER_ONbefore 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.
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_epdexample 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
epdsican select a different waveform.
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.
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
