Fixing Waveshare e-paper display partial updates

I’m in the process of making a custom “smart frame”, i.e. a device that looks like a photo frame but contains an e-paper (aka e-ink) display that shows useful information (date and time, weather, calendar…)

For that project I acquired a Waveshare 7.5" black-and-white e-paper display. Maybe I’ll make a post about the frame when it’s finished, but for now I’d like to talk about an issue I found in the official driver of the e-paper screen. And how, with some guesswork, I managed to fix it.

Types of e-paper display update methods

If you’ve owned a Kindle or some other ebook reader, you’ve probably noticed that the display doesn’t refresh the same way every time. Most of the time when you navigate from one page to the other, the new page appears immediately, just like it would on a normal screen. But sometimes, maybe every 5-10 page turns, the display flickers black and white a few times before showing the new page.

These multiple update methods are required because of how an e-paper display works, with tiny pigments that need to move in microcapsules. The fast updates can’t always move them all and occasional coercion via some flickering is required to erase remaining afterimages and improve contrast.

The Waveshare display’s firmware and original driver actually implements 4 update types:

Note that partial refresh is called that, “partial”, because it is the only method that allows refreshing only part of the display. But it is also the only refresh method that doesn’t flicker. In other words, it makes sense to use “partial refresh” even when refreshing the whole screen, just to avoid the flickering.

Varying the refresh methods…

As can be experienced with ebook readers, it is recommended to vary the update methods, as using only the non-flickering partial refresh may lead to poor contrast, remaining afterimages, and could even damage the display over time.

In my “smart frame”, I want to show a clock (among other things) and therefore need to refresh the display every minute. Therefore I opted for this schedule of refresh methods:

That way, 9 out of 10 updates will not flicker but the screen will still be fully refreshed regularly.

… and the issue with partial updates

However, after implementing the schedule, I immediately noticed that something was very wrong with partial updates. For example, one minute after displaying 10:40 correctly with a fast refresh, the partial refresh that should have shown 10:41 instead produced this strange result:

photograph of epaper showing corrupted, noisy characters

Partial updates may lack some contrast in the areas going from black to white or vice-versa, but they should not be noisy like that.

What was strange was that I tested partial updates before, and Waveshare’s demo code contains them and they do work fine. But in the demo code, the partial updates are done immediately after the previous (full or fast) refresh. Could the issue be time-sensitive?

Indeed, when issuing 10:41 via partial refresh immediately after showing 10:40, the correct result was achieved:

photograph of epaper showing a clear 10:41

It seems the issue was caused by waiting one minute before the partial update. What about intermediate delays?

basically the same as the previous image
after 1s, still looks good
a rather clear 10:41 with some extra black pixels in the last character
after 2s, uh oh, some black pixels remain where the 0 was
more noise in the last character
after 3s, many more black pixels, and some white ones too…
lots of noise in the last character but the 10:4 part still looks good
after 4s, lots of noise in that last position
noise visible in all characters
after 6s, the noise has spread!
even more noise visible in all characters
after 8s, basically as noisy as it gets (similar to the 1 min result)

So… what’s in electronics and gets noisy after a few seconds? Well, of course, RAM does.

The RAM volatility hypothesis

I didn’t mention it before, but if you have to wait between display updates, you’re supposed to put the display to sleep. This de-energizes the display and its microcontroller, which not only saves power but is also recommended to avoid damage to the display and to extend its life. So, of course, that’s what I was doing since I only needed to update the display once a minute.

At that point, I had a pretty strong hypothesis in mind:

But what could I do about it? I could stop using partial updates and just use fast refresh every minute, that would work fine. But I wasn’t too happy about having the display flicker every minute as it’s a bit distracting. Partial updates would be really nice, if only they worked after putting the display to sleep…

A tale of two buffers

So I took a look at Waveshare’s driver code, hoping to find something.

(Well actually, I looked at my own fork of Waveshare’s driver, since I had already improved it in other ways that are irrelevant here; I hadn’t touched the partial update logic and I confirmed that the original code had the same issue. Still, in this section, I’ll show Waveshare’s code.)

And I did find something interesting!

Both full & fast refresh use the same display method which sends a 0x10 command followed by image data, then a 0x13 command followed by another image data.

def display(self, image):
    # ...
    self.send_command(0x10)
    self.send_data2(image1)

    self.send_command(0x13)
    self.send_data2(image)
    # ...

More precisely, the image sent after the first command is the negative of the image we want to display, the second one is the actual image to display. The image and its negative is what flickers on the screen with these update methods.

Even the grayscale refresh method uses the two 0x10 and 0x13 commands followed by image data, even though in that case it’s the low and high bits of each 2-bit/4-gray pixel.

It looks a lot like these two commands are used to send data to two distinct buffers in the RAM of the display’s MCU, doesn’t it?

What about partial refreshes? They use a different method, display_Partial. We can see our 0x13 command and its image data, as well as a comment that confirms our suspicion:

def display_Partial(self, Image, Xstart, Ystart, Xend, Yend):
    # ...
    self.send_command(0x13)   #Write Black and White image to RAM
    self.send_data2(image1)
    # ...

But, no 0x10.

What if…

What if the partial update still uses what’s in the 0x10 buffer even if it doesn’t set it?

What if it expects it to be what’s been set by the previous display method, and uses that as the basis for what’s currently on the display and which pixels it needs to flip?

What if it’s that buffer whose RAM gets corrupted?

Could we keep the partial update from using that corrupted data?

We can just keep a copy of the buffer in the driver; and use the 0x10 command to set the buffer with that data after waking up from sleep, when doing the partial refresh. In essence, we only need to add a few lines:

def display(self, image):
    # ...
    self.send_command(0x10)
    self.send_data2(image1)
    # ADDED:
    self.buf10_copy = image1  # Save a good copy of the 0x10 buffer for later...

    self.send_command(0x13)
    self.send_data2(image)
    # ...

def display_Partial(self, Image, Xstart, Ystart, Xend, Yend):
    # ...
    # ADDED:
    self.send_command(0x10)
    self.send_data2(self.buf10_copy)  # ...restore the data of the 0x10 buffer

    self.send_command(0x13)   #Write Black and White image to RAM
    self.send_data2(image1)
    # ...

So I just tried that. Guess what? It worked. The “10:41” partial update, even after sleeping a full minute, didn’t show any noise corruption anymore.

The code

If you too want working partial-updates-after-sleep with your Waveshare 7.5" screen, take a look at my driver implementing the fix described above:

https://github.com/hchargois/betterepd7in5/

Actually even if you don’t care about partial updates, you might prefer it over Waveshare’s original driver since it’s faster, less CPU-hungry, simpler and safer to use.

And it’s on PyPI so you simply have to pip install betterepd7in5 (or actually uv add betterepd7in5 because you know, it’s 2025, just use uv).

If you’re curious and want to see how the fix is actually implemented, look at this commit.

The implementation is actually just a tiny bit more complex than just saving and restoring the buffer data as explained above. Because partial updates can follow one another, and also because partial updates can potentially update only part of the screen, instead of saving and updating the actual buffer data, I found it simpler to keep track of the currently displayed image as an actual PIL image, so I could paste the updates on it in the correct positions.

Also, because restoring the buffer contents takes some (very small but non-nil) amount of time, for best performance it checks whether the display was put to sleep before the partial update. If not, the RAM could not have become corrupted so there’s no need to spend time restoring the buffer.