Basic Guide How to Start Using TFT & EPD Graphic Library 2
|
Quick Reference: TFT vs EPD |
||
|
Property |
TFT/LCD |
EPD |
|
Update mechanism |
Electrical (backlight/LC state) |
Mechanical (pigment movement) |
|
Update latency |
Microseconds |
~100ms to several seconds |
|
Image visible without power |
No |
Yes |
|
Controller RAM after power-down |
N/A (redrawn continuously) |
Lost - image on panel persists, RAM does not |
|
RAM addressing |
Pixel/byte(s)-per-pixel |
Bit-per-pixel, byte-accessed only |
|
RAM rotation support |
Often built in (MAC register) |
Usually none - handled in software |
|
Single-pixel edit |
Direct write |
Read-modify-write of 7 neighbor bits, usually with no readback |
|
Temperature sensitivity |
Minor |
Significant - affects waveform timing |
|
Pixel lifespan |
Effectively unlimited |
Finite - degrades with refresh count |
|
Refresh visible effect |
None |
Flashing/inversion during full refresh |
|
Typical color planes |
1 (RGB combined) |
1-2 separate bit-planes (BW, RY) |
Electrophoretic (E-Paper/EPD) displays work on a fundamentally different principle than TFT/LCD screens, and that difference shapes almost every design decision in this library.
A TFT pixel is a switch: current flows, the sub-pixel lights up or changes state, and the change is visible within microseconds.
An EPD pixel is a physical mechanism: black and white (and, on 3-color panels, colored) pigment particles suspended in a fluid are moved by an applied electric field until they settle at the top or bottom of a microcapsule.
You are not turning a light on or off — you are physically pushing pigment through a fluid, and that takes real, mechanical time.
TFT Library Virtualization Layer - BUFFERED EPD PROCEDURES (COMMANDS)
With the expansion of TFT Graphic Library 2 into TFT & EPD Graphic Library 2 (since version 2.03), a virtualization layer has been added that lets most TFT Graphic Library 2 functions be called exactly as if targeting a TFT display, while internally accounting for the EPD specifics described above.
This requires a frame buffer — either internal MCU RAM or an external SRAM chip; see [EPD_Graphic_Lib.exe section] for sizing and configuration details.
The frame buffer exists because of the same row/column, byte/bit-oriented addressing constraint mentioned earlier: an EPD panel can't be drawn to incrementally, shape by shape, the way a TFT can, since there's no immediate, per-pixel write to build an image up against.
Without a frame buffer, printing anything beyond the simplest full-screen images or some simple SND fonts and shapes would mean pre-rendering and storing complete or partial bitmaps in MCU flash ahead of time — workable for a handful of fixed screens, but impractical for anything dynamic or composed at runtime.
With the frame buffer in place, shapes, overlays, and merges can be assembled in RAM or external SRAM using the same drawing calls you'd use on a TFT, then pushed to the panel as a single refresh once the frame is complete — sidestepping flash storage entirely for content that changes at runtime.
It also makes practical use of BDF fonts for text, which are otherwise difficult or effectively impossible to render directly on EPD hardware given the irregular glyph spacing and positioning BDF fonts require.
The commands listed in EPD-Buffered section are the ones defined directly in the EPD library code.
Some of them (prefixed Tft) are carried over from TFT Library 2 but required modification for EPD use — TftBmpFromCode() is one example.
Beyond what's listed there, you can also use TFT Library 2 other commands directly — from System.inc, Shapes.inc, SND.inc, and BDF.inc — without any EPD-specific modification, including TftBmpFromCodeRotate(), TftFromCodeRotate180(), TftFromCodeRotate90(), TftFromCodeRotate270(), TftArc(), TftStar(), TftPolygon(), TftPrintSndString(), TftPrintBdfString(), and the rest of TFT Library 2 shape, image, and font functions.
These all work correctly against an EPD in buffered mode because they draw through TftPixel(), which the virtualization layer redirects into the RAM/SRAM frame buffer, exactly as described earlier.
If you use a RAM-based frame buffer, there's a hardware/compiler limitation worth understanding before you plan around it: RAM above the 32KB boundary cannot be addressed directly on these PIC MCUs, even on devices with more RAM physically built in.
This comes down to the chip's own silicon design, not anything Positron chose. Up to 32KB, RAM sits in a region the instruction set can reference directly, in a single instruction.
Beyond that, Microchip's devices switch to a paged addressing scheme: before you can read or write anything above 32KB, you first have to set dedicated page-select registers (PSRPAG/PSWPAG) to choose which page of extended RAM you're pointing at, then perform the access as a separate step.
It's not a limitation Positron introduced — Les (Positron's developer) confirmed this, Microchip's own compiler and a third-party one (CCS) have the identical 32KB ceiling in both.
The chip itself simply doesn't support direct addressing past that point; every compiler targeting it inherits the same constraint.
Forcing every RAM access through the indirect, paged mechanism everywhere — not just above 32KB — would be technically possible, but at the cost of meaningfully larger, slower generated code for every variable access, not just the ones that need it.
That's not a tradeoff a general-purpose compiler can make by default, which is exactly why the 32KB direct-access limit exists as a firm, deliberate boundary rather than something worked around silently.
What this means for a RAM-based frame buffer, in practice: a single linear buffer that crosses the 32KB line does not reliably work — specifically, access to any variable aliased onto a portion of a buffer sitting above that boundary does not correctly follow the required paging, even though a plain access to a large string variable straddling the same boundary does.
Given how central aliasing is to how this library's buffers are structured, this makes a single frame buffer larger than 32KB unsafe to rely on today.
The practical workaround: declare a single, smaller than 32KB RAM buffer:
and manage which plane (Black/White or Red/Yellow) currently occupies it yourself. This means building one plane's image data fully in the buffer, sending it to the driver IC's GRAM, then reusing the same buffer to build and send the second plane.
It takes a bit more care and a clear understanding of your image-composition sequence, but it works within the hardware's real constraints rather than fighting them.
If your display is large enough that even one plane doesn't comfortably fit in 32KB, an external SRAM chip is the better fit — and it isn't a niche option, since SRAM access goes through its own SPI-driven addressing, sidestepping the MCU's internal paged-RAM limit entirely.
Frame buffer size works out to (width × height × planes) / 8 bytes, where planes is 1 for BW or 2 for BWR/BWY.
|
Chip size |
Capacity (bytes) |
Max pixels — BW (1 plane) |
Max pixels — BWR/BWY (2 planes) |
|
1Mb |
131,072 B (128 KB) |
1,048,576 px |
524,288 px |
|
2Mb |
262,144 B (256 KB) |
2,097,152 px |
1,048,576 px |
|
4Mb |
524,288 B (512 KB) |
4,194,304 px |
2,097,152 px |
For concrete reference, some panel sizes and how many of each panel's buffer a given chip can actually hold:
|
Panel / mode |
1Mb chip |
2Mb chip |
4Mb chip |
|
400×300 BW |
8x |
17x |
34x |
|
400×300 BWR/BWY |
4x |
8x |
17x |
|
800×600 BW |
2x |
4x |
8x |
|
800×600 BWR/BWY |
1x |
2x |
4x |
A smaller panel like 400×300 can fit comfortably in internal MCU RAM only one buffer, so the 32KB limit and the SRAM discussion barely apply to the most low-cost EPD modules and currently supported driver ICs.
It's specifically panels above 400x300 BWR and up where the buffer + program genuinely exceed 32KB and external SRAM becomes necessary.
An example of plane management for the RAM-buffer workaround is available here: BWR EPD Manage 1 RAM buffer
A "waveform" is the sequence of voltage pulses (magnitude, polarity, and duration) the controller applies to drive pigment from its current state to the target state.
Different transitions need different pulses — going from black to white is not simply the reverse of white to black, and a full refresh uses a different, more thorough sequence than a fast partial update.
These pulse sequences are organized internally as Look-Up Tables (LUTs) — the controller looks up, for a given old-state/new-state/temperature combination, which pulse sequence to apply.
Multiple flashes/color inversions during a full refresh are the waveform doing its job, not a fault — expect it, and don't design a UI that assumes a silent, instant transition.
This library uses only the waveforms built into the display's own controller chip, invoked through the manufacturer's driver — it does not implement or substitute custom waveforms.
These are programmed at the factory (commonly stored in OTP memory on the controller), tuned by the panel manufacturer for that specific panel's pigment and fluid characteristics.
Critically, this factory-programmed default is typically a full-refresh waveform only — a genuinely different, dedicated LUT for partial updates isn't necessarily present or active by default, and on many controllers has to be explicitly loaded before it can be used.
This distinction matters enough that it gets its own explanation below, under Partial Update.
A bare EPD panel is only usable once paired with a controller that has these waveforms already loaded — this is one reason EPD modules are sold as panel+driver-board combinations rather than raw glass.
Some driver ICs support faster partial-update waveforms that redraw only a changed region of the screen — in principle much quicker than a full refresh.
In practice, support is inconsistent, for two separate reasons worth understanding.
The first is panel-level: partial-update quality depends not just on the controller IC but on the specific panel's own OTP waveform data, programmed by the manufacturer.
Even where advertised, quality varies significantly between panels and is frequently unreliable on inexpensive modules, often leading to visible ghosting that accumulates over successive partial updates.
The second is specific to this library's current implementation, and differs between buffered and bufferless mode.
In buffered mode, partial update isn't currently implemented at all — supporting it correctly would require transferring only the changed portion of the frame buffer to the display, rather than the whole thing, and that needs more careful handling than the library currently provides.
In bufferless mode, partial update is available only on some driver ICs, and the reason comes down to how each controller's internal GRAM is organized.
A proper partial-update waveform needs to know not just what a pixel should become, but what it currently is — the waveform that correctly moves a pixel from white to black is different from the one needed for black to white, or for no change at all, and choosing the right one requires comparing the pixel's previous state against its new target state.
That's a fundamentally different thing from the Black/White or Red color plane distinction most EPD controllers already have: color planes exist to separate which ink layer a pixel belongs to, not to remember what that pixel displayed a moment ago.
Some driver ICs' GRAM only ever holds "what to display now," for each color plane, with no separate space set aside for "what was displayed before" — the old content is simply overwritten the instant new data is written.
Without that previous-state memory, the controller has no way to know which specific transition a pixel needs, and so it can't select a correct partial waveform — it can only fall back to the same full, flash-through waveform used for a complete refresh.
On these controllers, calling EpdPartialUpdate() will simply perform a full frame update instead — a safe, correct fallback, just not a faster one.
Even on a controller whose GRAM does support the necessary old/new state tracking, there's a second, separate requirement that has to be met before partial update actually runs faster: the correct partial-refresh LUT has to be explicitly loaded first.
On most displays, the mode byte written to Display Update Control 2 doesn't select "a partial waveform" directly — it selects which internal steps the controller runs (enable clock, enable analog, load temperature, load LUT, and so on).
The actual waveform timing comes entirely from whichever LUT happens to be loaded at the moment the update is triggered, and by default, only the factory full-refresh LUT is available.
Sending a different mode byte without also loading a genuinely different, dedicated partial-refresh LUT via the controller's LUT-write command results in the exact same waveform running underneath — different byte value, same actual duration.
This isn't a rare edge case; it's a well-documented pitfall, and other open-source EPD drivers have hit and reported the identical symptom.
Designing that dedicated partial-refresh LUT is itself real, panel-specific work — waveform timing that works well on one panel isn't safe to assume will work correctly on another, even when both use the same controller chip.
This library does not currently implement that LUT-loading step, which is why EpdPartialUpdate() today behaves the same way as the no-previous-state case above: safe to call, but not yet faster than a full update.
Full-frame updates are best preceded by an explicit display reset, particularly on older controller IC generations — this ensures the controller begins the update from a known state rather than compounding onto whatever waveform state it was previously left in.
Given all of this, EpdPartialUpdate() exists as a callable command in the TFT & EPD Graphic Library 2, but at present it always performs a full update under the hood, on every controller — the underlying LUT-loading work described above hasn't been implemented yet.
I recommend designing around full-frame generation and full refresh as the default approach for now, and treating partial update as a future optimization rather than something to rely on today.
I plan to return to this — both buffered-mode partial updates, broader bufferless-mode support, and proper partial-refresh LUT loading for the controllers capable of it — in a future revision.
Physical Panel Considerations: VCOM and the Border Line
Two aspects of the panel's physical wiring are worth understanding before you spend time debugging what looks like a software problem but isn't.
VCOM voltage: Each panel is factory-calibrated with a specific common voltage (VCOM) that the controller must be set to for correct contrast and to avoid long-term ghosting or image burn-in.
This value is panel-specific, not IC-specific — it's usually printed on the panel's flex cable or listed in its datasheet, and must be set in the controller before use.
Two otherwise-identical panels from different manufacturing batches can require different VCOM values; if you swap panels, check this before assuming a driver fault.
Border line: EPD panels always show some border/frame color around the active pixel area — this is a physical consequence of how the panel is constructed and connected, not something that can be made to disappear.
Many controller ICs expose a border/VCOM-related register or LUT specifically to control this border's color independently of the main image.
However, whether this is actually controllable in practice depends on how the border signal is wired at the panel level, not just on what the controller IC's datasheet claims it supports — some panels tie the border connection directly to VCOM or ground at the glass/FPC level, bypassing the controller's programmable border function entirely.
On these panels, writing to the border register has no visible effect, or behaves inconsistently, regardless of what the IC is capable of in general.
If your border isn't responding as the datasheet suggests it should, this — rather than a software bug — is the most likely explanation, especially on inexpensive modules.
EPD controller RAM is organized very differently from a TFT framebuffer, and this is the part most likely to trip up code ported from TFT experience.
Because of this, drawing and updating graphics requires one or a combination of:
Communicating With the Controller: The Busy Pin
Unlike a TFT write, an EPD refresh command doesn't complete when the MCU finishes sending it — the controller continues driving the waveform internally, on its own clock, for as long as the update takes.
Almost all EPD controllers expose a BUSY pin specifically so the host can tell when this internal process has actually finished, and a new command must not be issued while the controller is still mid-refresh, or the in-progress update can be corrupted.
This library offers two ways to handle BUSY, selected via the BUSY configuration option:
SW mode: (default) After an update-related command is issued to the EPD, the library waits for the BUSY pin to clear before returning, blocking code execution for the duration.
The calling code simply continues normally once the command completes, with no BUSY handling of its own required.
IRQ mode: The command is sent and returns immediately, without waiting for BUSY. The program is free to do other work in the meantime.
It is then up to the user to determine when the controller has actually finished — either by polling the BUSY pin periodically, or by wiring it to a hardware interrupt and resuming EPD-related work when that interrupt fires.
The defining difference is simply that in IRQ mode, the command does not wait on BUSY itself; how the user chooses to detect completion is up to them.
SW mode is the library's default and is recommended unless your application specifically needs the MCU to remain free during a refresh.
This distinction matters because handling BUSY incorrectly is a common source of intermittent, hard-to-reproduce glitches in EPD projects - particularly when timing is handled manually, or when code is ported from display types where every write completes synchronously and no such wait is needed at all.
Full refresh after power-on. Controller RAM is cleared on power reset, and old pigment position doesn't automatically get accounted for on the next update.
A full refresh/reset cycle is needed to establish a known clean state and avoid ghosting (faint traces of the previous image persisting after an update).
Pixel life is limited. Unlike TFT, which is effectively unlimited for practical purposes, EPD pixels degrade with repeated pigment movement over their lifetime.
Refresh only when the content actually needs to change, and avoid unnecessary or purely cosmetic refresh cycles.
Power the panel down after refreshing. The image physically persists on an EPD with zero power draw — this is the defining advantage of the technology.
Over long periods without power, the pigment can slowly diffuse back toward a neutral position, which is why long-term-displayed images may need an occasional refresh even with no content change.
Controller RAM does not survive power-down. Only the physical pigment position on the panel persists — the controller's RAM contents are lost.
If your application needs to update or redraw part of the image later, you must either retain a copy of the frame buffer elsewhere (external memory, or in the MCU, if power to that portion is maintained) or be prepared to regenerate the full image on power-up before the next update.
Sleep vs. deep sleep. Many controllers offer more than one low-power mode. A standard sleep/standby mode may retain enough internal state to resume normally, while a deep sleep mode saves more power but typically requires a full re-initialization on wake — including reloading VCOM and repeating the reset sequence — before the next update.
Check your specific controller's datasheet for which state your power-down routine actually leaves it in, since the two are not interchangeable in what they preserve.
Unlike a TFT display, an EPD panel needs no power at all to hold its current image — once pigment has settled into position, it stays there indefinitely with zero refresh current, which is the whole basis of e-paper's characteristic low power draw.
Deep Sleep exploits this directly: it shuts down the driver IC's internal DC-DC converter (the charge pump generating VGH/VGL/VCOM and similar analog rails) almost entirely, since none of it is needed to keep the already-displayed image visible — only to change it.
This makes Deep Sleep the correct state for a device that's finished updating the display and doesn't need to touch it again for a while, giving the lowest possible power draw between updates.
To exit Deep Sleep, the driver IC needs a hardware reset. EpdSetUp() performs this by toggling the RESET pin, so calling it should bring the driver IC back out of Deep Sleep — and per the driver's own datasheet, a plain hardware reset is documented as sufficient on its own.
In practice, this hasn't always been reliable across every module tested — on some, only a full power-off/power-on cycle recovers the display.
Looking more closely at the datasheet clarifies why this is plausible, even though a bare RESET is documented as sufficient.
Both Deep Sleep modes it defines turn the DC-DC converter off entirely — they differ only in whether GRAM contents are retained (Mode 1 retains them but leaves them inaccessible while asleep; Mode 2 does not retain them at all).
More tellingly, the datasheet's own recommended power-off sequence lists entering Deep Sleep as the step immediately before physically removing power — meaning Deep Sleep appears to be designed primarily as a "prepare to lose power" state, not necessarily as a state meant to be exited reliably via RESET alone while power stays continuously applied.
Given that framing, whether a RESET-only toggle correctly brings the DC-DC converter back to life likely depends on board-specific details — how that converter's enable/reset logic is actually wired on a given module — rather than being something the datasheet fully guarantees across every implementation.
That would explain the inconsistent, board-dependent behavior observed: some modules recover cleanly via RESET alone, others don't, depending on specifics the datasheet's simple "send HWRESET" statement doesn't fully capture.
This remains an open area I intend to investigate further — if you're running into this on your own module, a full power-off/power-on cycle after Deep Sleep is the safe, reliable fallback in the meantime.
Created with the Personal Edition of HelpNDoc: Easily share your documentation with the world through a beautiful website