DIY E-Ink Photo Frame

April 23, 2023

I’ve always been a huge fan of both the e-ink technology and the DIY projects you can do with them. I’ve also been slowly building up my own fantasy world, Valinde, for a few years now and have commissioned a fair bit of art in black and white, so when I spotted you can purchase an e-ink screen for under £100, back in 2021, I knew I wanted to try and experiment with it to produce a digital picture frame. Here’s a recap of the project from start to finish.

The finished product
The finished product

#The Hardware

My starting point for the project was a Waveshare EPD 7.5inch v2 in black and white which I paired with a Raspberry Pi Zero W board I had lying around. The Waveshare display has a resolution of 800x480 and can display in exactly two colours, black and white. It connects to the Raspberry Pi using the SPI interface and a HAT module that connects to the Raspberry Pi’s HAT-compatible 40-pin header.

#The Software

Waveshare kindly provide some documentation, that describe enable SPI the Pi and to download the BCM2835 C library used to access the Pi’s GPIO. They also provide some example code and some descriptions about how to interface with the Waveshare display in Python, but that would be far too easy.

Instead of adopting their example code I decided to write a small program in Rust instead using the epd_waveshare crate.

The program, displayed below, sets up the connection to the Waveshare display using SPI, selects a random file in the program’s directory with a matching .txt extension, reads the files in as bytes, and then writes those bytes to the e-ink display. Not too complicated!

use std::{
    fs::{self, File},
    path::{PathBuf, Path},

use embedded_graphics::image::Image;
use embedded_graphics::{
    pixelcolor::{raw::BigEndian, BinaryColor},
use epd_waveshare::{
    epd7in5_v2::{Display7in5, Epd7in5},
use rppal::gpio::Gpio;
use rppal::hal::Delay;
use rppal::spi::{Bus, Mode, SlaveSelect, Spi};

use anyhow::{Context, Result};
use rand::seq::SliceRandom;

const WIDTH: u32 = 800;
const IMAGE_EXTENSION: &str = "txt";

fn main() -> Result<()> {
    let display_path: Option<String> = env::args().nth(1);

    let (mut spi, mut epd7in5, mut delay) =
        setup_waveshare().with_context(|| "Failed to initialise waveshare display")?;
    let mut display = Display7in5::default();

    let data = match display_path {
        Some(path) => {
            let path = Path::new(&path).to_path_buf();
        None => get_random_image().with_context(|| "Failed to get random image")?

    let raw_image = ImageRaw::<BinaryColor, BigEndian>::new(&data, WIDTH);
    let image = Image::new(&raw_image, Point::zero());

        .draw(&mut display)
        .with_context(|| "Failed to draw to screen")?;

        .update_frame(&mut spi, display.buffer(), &mut delay)
        .with_context(|| "Failed to update frame")?;
        .display_frame(&mut spi, &mut delay)
        .with_context(|| "Failed to display frame")?;

    println!("Finished rendering - going to sleep");
        .sleep(&mut spi, &mut delay)
        .with_context(|| "Failed to sleep")?;

fn get_random_image() -> Result<Vec<u8>> {
    let entries: Vec<PathBuf> = fs::read_dir(".")
        .with_context(|| "Failed to read directory")?
        .filter_map(|file| file.ok())
        .filter(|entry| {
            let path = entry.path();
            let extension = path.extension().and_then(OsStr::to_str);
            match extension {
                Some(extension) => extension == IMAGE_EXTENSION,
                None => false,
        .map(|entry| entry.path())
    let chosen = entries
        .choose(&mut rand::thread_rng())
        .with_context(|| "Failed choose image file as there are none available")?;

fn get_image(path: &PathBuf) -> Result<Vec<u8>> {
    let mut data = Vec::new();
        .with_context(|| format!("Failed to open file {}", path.display()))?
        .read_to_end(&mut data)
        .with_context(|| format!("Failed to read file {} to end", path.display()))?;

fn setup_waveshare() -> Result<(
)> {
    // Activate SPI, GPIO in raspi-config
    // needs to be run with sudo because of some sysfs_gpio permission problems and follow-up timing problems
    // see and follow-up issues

    // This code matches the pins described in
    // It also matches the code from
    let mut spi = Spi::new(Bus::Spi0, SlaveSelect::Ss0, 4_000_000, Mode::Mode0)
        .with_context(|| "Unable to configure SPI")?;
        .with_context(|| "Set bits per word")?;

    let mut rst = Gpio::new()
        .with_context(|| "Failed to get GPIO")?
        .get(17) // Board 11 BCM 17
        .with_context(|| "Failed to get BCM Pin 17 for RST")?

    let mut dc = Gpio::new()
        .with_context(|| "Failed to get GPIO")?
        .get(25) //Board 22, BCM 25
        .with_context(|| "Failed to get BCM Pin 25 for RST")?

    let mut cs = Gpio::new()
        .with_context(|| "Failed to get GPIO")?
        .get(8) //Board 24, BCM 8
        .with_context(|| "Failed to get BCM Pin 8 for RST")?

    let busy = Gpio::new()
        .with_context(|| "Failed to get GPIO")?
        .get(24) // Board 18, BCM 24
        .with_context(|| "Failed to get BCM Pin 24 for RST")?

    let mut delay = Delay {};
    let epd7in5 = Epd7in5::new(&mut spi, cs, busy, dc, rst, &mut delay)
        .with_context(|| "eink initalize error")?;
    Ok((spi, epd7in5, delay))

If you read through the code you’ll notice I need to access specific pins. I created a lot of headaches for myself by not understanding which pin number was being accessed. Raspberry Pi’s have their own pin numbering which doesn’t match the BCM pin number used to access the pin in software, but this is relatively clear if you read the Waveshare example code.

#The Images

To display files on the display, the .txt file that contains the image data must consist entirely of bits of 0 and 1. Most images, like are encoded in a specific image format like PNG or JPEG which encode additional information we don’t need, and are overkill for our binary model of displaying colour at a fixed resolution.

To create this binary image file I first positioned and cropped my black and white image in Affinity Photo to the 800x480 size for the Waveshare display. I then use an online Binary Image tool to read the image and convert it to a binary string.

I then wrote a small Rust program to take a string of 1s and 0s and to write out a binary file which can be read by our display program. You’ll notice that both programs write to files with the extension .txt, but this extension is meaningless, any file extension could be used instead.

I’m a little embarrassed to be using an online tool as part of this flow, but at the time I’d wasted far too long getting everything working by this point and writing an application that decoded images was a little too much effort. I might work on this in the future though.

Almost finished! The E Ink display finally displaying images!
Almost finished! The E Ink display finally displaying images!

#Pulling Everything Together

Using SSH, I transferred over a few images I’d converted into a binary format as well as the program to randomly select one and write it to the display. The program is set to run every 30 minutes using crontab.

To finish things off, I bought a photo frame and filed down the inside of the frame to make sure the Waveshare display fit exactly and taped the Raspberry Pi Zero to the back of the frame to hide it. I’m really pleased with the end result, and I’m looking at picking up an 8-bit greyscale screen in the future to display images that aren’t suited to this exclusively black and white display.

Last Updated: 2023-04-23 13:28