Micropython E-ink Display Rotation

By default, sending pixels from a Raspberry Pico to a Waveshare e-ink display will result in a portrait image. In the case of the 2.13inch display I started with, this means a screen of 122 pixels wide by 250 pixels high.

But what if you want to display in portrait mode? As someone who’s used to the simple world of CSS it was actually fun to get back into the world of bits and pixels again.

Just like the ZX Spectrum, where I started programming, the display is stored as a list of bytes where each byte is eight consecutive pixels, either set to 1 to display a dark pixel or 0 for white (or clear).

In Micropython, this is stored as a byte array. So the code that sets up the display looks like this:

self.buffer_black = bytearray(self.height * self.width // 8)

Thankfully, there’s a helper module in the shape of Framebuffer which makes changing the value of these pixels much easier. It contains methods to write text, lines, rectangles and circles and, most usefully, to draw images at arbitrary coordinates using the blit command.

Not only does blit make it easier to draw, say, a 50×50 image onto the 122×250 display, but it also makes it much easier to draw an 8-bit image onto the 1-bit display. You start by setting a Framebuffer object with its data source as the byte array, such as:

self.imageblack = framebuf.FrameBuffer(self.buffer_black, self.width, self.height, framebuf.MONO_HLSB)

Where the first argument is the byte array, with and height are self explanatory, and framebuf.MONO_HLSB means that this framebuffer is a mono (1-bit) image, with horizontal bits stored as a byte.

To draw an image on top, assuming you have the pixel data in a variable called image_buffer, you can do something like:

self.imageblack.blit(framebuf.FrameBuffer(image_buffer, width, height, framebuf.GS8), x, y, key)

Where width and height are the width and height of the image, framebuf.GS8 signifies an 8-bit grayscale image, x and y are the coordinates to draw to, and key tells the blit method which pixels in the image it should treat as transparent. This allows you to draw just the black pixels onto the screen without the “white” pixels (or 0 bits) overwriting anything that’s already on the screen. Because the frame buffer takes the image_buffer bytearray as a parameter but changes the pixels in it by reference, any changes to self.imageblack (which is a Framebuffer object) are applied to the bytearay in self.buffer_black – which represents the actual pixel data.

So far so simple as far as drawing an image to the screen goes, assuming you’re still working in portrait mode. I made myself a helper function to load raw files from the on-board memory:

def drawImage(display_buffer, name, x, y, width, height, background=0xFF):
    image_data = get_image_data(name)
    if image_data:
        display_buffer.blit(image_data, x, y, background)
    else:
        image_buffer = bytearray(width * height)
        filename = 'pic/' + name + '.raw'
        with open (filename, "rb") as file:
            position = 0
            while position < (width * height):
                current_byte = file.read(1)
                # if eof
                if len(current_byte) == 0:
                    break
                # copy to buffer
                image_buffer[position] = ord(current_byte)
                position += 1
        file.close()
        display_buffer.blit(framebuf.FrameBuffer(image_buffer, width, height, framebuf.GS8), x, y, background)

Drawing a landscape image instead of portrait should be as simple as:

  1. Set up a Framebuffer with a width equal to the e-ink display height, and a width equal to the e-ink display width
  2. Draw to the Framebuffer
  3. Transpose columns of pixels into rows in a new bytearray
  4. Send to the e-ink display

It seemed like exactly the sort of thing that someone would already have tackled, and so it proved. So with some Googling and adjustment of width/height variables to suit the display I was working with I came to this:

def rotateDisplay(display):
    epaper_display = bytearray(epd.height * epd.width // 8)
    x=0; y=-1; n=0; R=0
    for i in range(0, epd.width//8):
        for j in range(0, epd.height):
            R = (n-x)+(n-y)*((epd.width//8)-1)
            pixel = display[n]
            epaper_display[R] = pixel
            n +=1
        x = n+i+1
        y = n-1
    epaper_buffer = framebuf.FrameBuffer(epaper_display, epd.width, epd.height, framebuf.MONO_HLSB)
    return epaper_display;

And I have to confess I didn’t worry too much about the details. Not until I ran it and the output was complete garbage.

It took a while to work out what I was doing wrong, but once I found the problem the solution made complete sense: I was inputting a Framebuffer stored as MONO_HLSB but the function above transposed the position of bytes, not individual bits. In other words, chunks of 8 pixels were being moved to the right place on the screen, but forming a column rather than a row. No wonder it looked bad.

The solution was simple: start with a Framebuffer of the type MONO_VLSB, where the bytes represent a column of 8 pixels, not a row. Then the transposition naturally mapped to MONO_HLSB.

I ended up with something like this:

display = bytearray(epd.height * epd.width // 8)
    display_buffer = Screen(display, epd.height, epd.width, framebuf.MONO_VLSB)
    display_buffer.fill(0xff)
    --- 
      Draw somethings here
    ---
    epaper_display = rotateDisplay(display)

where Screen was an extension of the Framebuffer class, necessary because the width and height parameters weren’t accessible in a native Framebuffer object. It just felt nicer to store them “in place” rather than pass another parameter around.

class Screen(framebuf.FrameBuffer):
    def __init__(self, display, width, height, encoding):
        self.display = display
        self.width = width
        self.height = height
        self.encoding = encoding
        return

The final wrinkles was that the e-ink display was 122 pixels “wide” (or now 122 pixels high, as I was trying to work in landscape mode), and therefore not a number that formed a whole number of bytes. Because of the way I was treating the screen, it meant my top left origin was at the coordinates (0, 6), which felt a bit nasty but was something I could live with. The alternative would have been to transpose the whole thing down 6 pixels using another Framebuffer, but frankly I couldn’t be bothered.

Writing To The E-ink Display With Micropython

I’d gotten as far as running a few lines of code on the Pico, so the next thing was to work out if I could display anything at all on the e-ink display. Since I’d already messed up by buying the wrong HAT type, I still had a small doubt about whether I would get the two to work together at all.

Waveshare has its own Github respository so I headed there to track down the correct Python file. I knew I needed one of the files that started with “Pico_ePaper-2.13-“, but which one?

There was a little stick on the display telling me I had a “v3”, but working out whether I had an A, B, C or D model was a mixture of guesswork and deduction. The file for both B and C appeared to support multiple colours, which is not what I had, so it wasn’t those. I had nothing else to go on, so I tried both A and D. Neither seemed to work properly, although I did get the screen to flicker a bit so something was happening.

I can’t even remember how I finally got the thing working, but it appears to be through a hybrid of files that started as Pico_ePaper-2.13-B_V4.py with all the code that referred to the red image data removed. The whole process was made more difficult by the fact that I seemed to have messed up connecting two of the pins, having not spotted that there was a pin labelled “GND” right between “GP21” and “GP22” on the Pico board.

This was my “driver” module:


from machine import Pin, SPI
import framebuf
import utime

EPD_WIDTH       = 122
EPD_HEIGHT      = 250

RST_PIN         = 12
DC_PIN          = 8
CS_PIN          = 9
BUSY_PIN        = 13

class EPD:
    def __init__(self):
        self.reset_pin = Pin(RST_PIN, Pin.OUT)
        
        self.busy_pin = Pin(BUSY_PIN, Pin.IN, Pin.PULL_UP)
        self.cs_pin = Pin(CS_PIN, Pin.OUT)
        if EPD_WIDTH % 8 == 0:
            self.width = EPD_WIDTH
        else :
            self.width = (EPD_WIDTH // 8) * 8 + 8
        self.height = EPD_HEIGHT
        
        self.spi = SPI(1)
        self.spi.init(baudrate=4000_000)
        self.dc_pin = Pin(DC_PIN, Pin.OUT)
        
        
        self.buffer_black = bytearray(self.height * self.width // 8)
        self.init()

    def digital_write(self, pin, value):
        pin.value(value)

    def digital_read(self, pin):
        return pin.value()

    def delay_ms(self, delaytime):
        utime.sleep(delaytime / 1000.0)

    def spi_writebyte(self, data):
        self.spi.write(bytearray(data))

    def module_exit(self):
        self.digital_write(self.reset_pin, 0)

    # Hardware reset
    def reset(self):
        self.digital_write(self.reset_pin, 1)
        self.delay_ms(50)
        self.digital_write(self.reset_pin, 0)
        self.delay_ms(2)
        self.digital_write(self.reset_pin, 1)
        self.delay_ms(50)


    def send_command(self, command):
        self.digital_write(self.dc_pin, 0)
        self.digital_write(self.cs_pin, 0)
        self.spi_writebyte([command])
        self.digital_write(self.cs_pin, 1)

    def send_data(self, data):
        self.digital_write(self.dc_pin, 1)
        self.digital_write(self.cs_pin, 0)
        self.spi_writebyte([data])
        self.digital_write(self.cs_pin, 1)
        
    def ReadBusy(self):
        print('busy')
        while(self.digital_read(self.busy_pin) == 1): 
            self.delay_ms(10) 
        print('busy release')
        self.delay_ms(20)
        
    def TurnOnDisplay(self):
        self.send_command(0x20)  # Activate Display Update Sequence
        self.ReadBusy()
        
    def TurnOnDisplayPart(self):
        self.send_command(0x20)        
        self.ReadBusy()

    def SetWindows(self, Xstart, Ystart, Xend, Yend):
        self.send_command(0x44) # SET_RAM_X_ADDRESS_START_END_POSITION
        self.send_data((Xstart>>3) & 0xFF)
        self.send_data((Xend>>3) & 0xFF)

        self.send_command(0x45) # SET_RAM_Y_ADDRESS_START_END_POSITION
        self.send_data(Ystart & 0xFF)
        self.send_data((Ystart >> 8) & 0xFF)
        self.send_data(Yend & 0xFF)
        self.send_data((Yend >> 8) & 0xFF)
        
    def SetCursor(self, Xstart, Ystart):
        self.send_command(0x4E) # SET_RAM_X_ADDRESS_COUNTER
        self.send_data(Xstart & 0xFF)

        self.send_command(0x4F) # SET_RAM_Y_ADDRESS_COUNTER
        self.send_data(Ystart & 0xFF)
        self.send_data((Ystart >> 8) & 0xFF)
    

    def init(self):
        print('init')
        self.reset()
        
        self.ReadBusy()   
        self.send_command(0x12)  #SWRESET
        self.ReadBusy()   

        self.send_command(0x01) #Driver output control      
        self.send_data(0xf9)
        self.send_data(0x00)
        self.send_data(0x00)

        self.send_command(0x11) #data entry mode       
        self.send_data(0x03)

        self.SetWindows(0, 0, self.width-1, self.height-1)
        self.SetCursor(0, 0)

        self.send_command(0x3C) #BorderWavefrom
        self.send_data(0x05)

        self.send_command(0x18) #Read built-in temperature sensor
        self.send_data(0x80)

        self.send_command(0x21) #  Display update control
        self.send_data(0x80)
        self.send_data(0x80)

        self.ReadBusy()
        
        return 0       
        
    def display(self):
        self.send_command(0x24)
        for j in range(0, self.height):
            for i in range(0, int(self.width / 8)):
                self.send_data(self.buffer_black[i + j * int(self.width / 8)])   
        self.TurnOnDisplay()

    
    def Clear(self, colorblack):
        self.send_command(0x24)
        for j in range(0, self.height):
            for i in range(0, int(self.width / 8)):
                self.send_data(colorblack)
        self.TurnOnDisplay()

    def sleep(self):
        self.send_command(0x10) 
        self.send_data(0x01)
        
        self.delay_ms(2000)
        self.module_exit()
        

I haven’t yet managed to track down what all the send_command statements actually do, but I feel sure there must be some documentation for them somewhere.

Anyway, finally, I had the equivalent of “hello world” on the display, without really knowing how. Also, I had discovered that the epaper display naturally preferred to display in portrait mode, which wouldn’t work for the layout I had in mind for my weather display.

    epd = EPD()
    epd.Clear(0xff)
    epd.imageblack = framebuf.FrameBuffer(epd.buffer_black, epd.width, epd.height, framebuf.MONO_HLSB)
    
    epd.imageblack.fill(0xff)
    #epd.imagered.fill(0xff)
    epd.imageblack.text("Waveshare", 0, 10, 0x00)
    epd.imageblack.text("ePaper-2.13-B", 0, 25, 0x00)
    epd.imageblack.text("RPi Pico", 0, 40, 0x00)
    #epd.imagered.text("Hello World", 0, 55, 0x00)
    epd.display()
    epd.delay_ms(2000)
    
    epd.imageblack.vline(10, 90, 40, 0x00)
    epd.imageblack.vline(90, 90, 40, 0x00)
    epd.imageblack.hline(10, 90, 80, 0x00)
    epd.imageblack.hline(10, 130, 80, 0x00)
    epd.imageblack.line(10, 90, 90, 130, 0x00)
    epd.imageblack.line(90, 90, 10, 130, 0x00)
    epd.display()
    epd.delay_ms(2000)

It felt like I’d conquered most of the unknowns anyway, so now onto showing some actual weather.