I bought a pair of ePaper displays from Waveshare with the intent to use them in a wireless sensor network for displaying slowly changing data. The reference code from Waveshare is quite clunky and not so easy to re-use, so I made my own small, portable graphics library and display driver (with ARM Cortex-M in mind).
The library is available on Github.
The code is split into the following parts (from low-to-high level functions):
- Hardware driver (disp_hw.c) – handles display-specific pins (like reset, busy, data/command) and the SPI bus. It has to be customized for the final PCB. This is the only non-portable part.
- Display driver (disp.c) – this module initializes the display and sends a complete framebuffer (I did not implement partial updates)
- Graphics module (graphics.c) – this is the “layout engine”. It is responsible for copying font bitmaps to the framebuffer to display texts. It supports proportional text (but no kerning).
- Resources and bitmaps (resources.c) – this file (and its header) is automatically generated from bitmaps and font JSON description files by a Python script (make_resources.py). Keep on reading to see how to use your own fonts.
- Some demo screens (screen.c) – two demos with different fonts that show how to use the library
Hardware driver (disp_hw.c)
This module has to be customized for your MCU and PCB. The functions are responsible for GPIO and SPI. Besides the header I also provided my implementation for reference. It will not build in another project, because it depends on my specific SPI driver for the STM32L151 and an I2C expander to do some GPIO (as I have used SP1ML that is quite pin-limited), but at least shows what the functions should do. SPI communication is done only one way (to the display). Display controller has no SPI data output pin.
The API is blocking (ie. each function returns only when the operation is done). This is a deliberate decision. The display will be used in a battery operated device, that does not have many other things to do while the image is updated (and if – it will happen in interrupt handlers). I recommend using DMA for SPI transfers and sleeping (via the __WFI(); in case of Cortex-M) in a while loop, until DMA complete interrupt sets a flag.
Display driver (disp.c)
Initialization sequence is taken from Waveshare reference code and contains many magic values that are written to the display. I did not investigate much. I have written the code to use command sequence arrays and simple code that iterates over them, than hardcoding the sequence. It is much easier to read data (hey! just simple arrays!), than to follow code. The LUT is double magic. From the controller’s datasheet I deduce that it is some kind of “microcode” for the controller that tells it how to drive the display. Fortunately the values provided by Waveshare work fine.
Application end of the module is an array called GLOBAL_framebuffer. It can simply be written outside of the module (I do know that globals are pure evil…). The framebuffer uses uint8_t to encode 8 bits. The display has 296×128 pixels. You may notice that DISPLAY_HEIGHT in the header is defined as 296 and DISPLAY_WIDTH is defined as 128. Like the display was oriented vertically. Here the fun starts…
When working with many LCD and OLED displays I usually found them to have a horizontal memory representation, ie. data bytes (interpreted as pixels) are interpreted left-to-right, top-to-bottom (scanning like a CRT TV does). This ePaper display is DIFFERENT. I use the display in the orientation shown in the first picture, ie. horizontally with the wiring on the left.
The controller claims to support many “scanning” modes (each direction left/right top/bottom can be switched), however the hardwired display orientation is vertical (ie. point 0,0 is in the corner with white cable). The default choice is to load data vertically from bottom to top, and from left to right. I wanted to have the framebuffer representation match the one in the display to avoid any data shuffling before transferring it to the display. It turned out that even if the display does left-to-right top-to-bottom scanning, the effect is that the incoming byte is still treated as 8 vertical pixels. That would complicate the layout even more, so I just made the framebuffer “rotated by 90 degrees”.
In the end the first byte in the framebuffer represents 8 vertical pixels starting in bottom-left, second byte represents next 8 pixels in the same column etc. This necessitates having all source bitmaps rotated by 90 degrees, but that is easily done in Python.
Graphics module (graphics.c)
This module has basically 3 operations: place bitmap, write text and refresh the display. Bitmaps and fonts are taken from resources.c generated by a Python script. They are simple C arrays plus some description how to interpret them. Bitmap payload representation follows the framebuffer – columns from bottom to top, from left to right (imagine that the image was first rotated by 90 degrees clockwise).
The main limitation is that each bitmap height has to be multiple of 8 pixels. It is to avoid having to shift and mask every write to the framebuffer. It is the limitation of this module only. Space character is treated specially (as it is not present in any of the bitmap files – why waste space for empty character) and is hardcoded as 1/4 of font height.
Demo screens (screen.c)
I prepared two demo screens (as seen in this post). One acts as a double temperature display. The other shows different font sizes. You can click the images to enlarge. The degree sign is a little special. I copy-pasted it from an online Unicode table to FontBuilder and it got decimal code 176. Another minor tweak to the autogenerated file is a shortened hyphen (-) in the large numbers font, to make it fit the display in case two temperatures are negative. The shortened hyphen is still legible. 🙂
Making custom fonts
Creation of new fonts and different sizes is described in this post.