--- title: "Using Waveshare e-ink screens without Raspberry Pi" date: !!timestamp '2023-08-02 11:37:00' image: /post/waveshare-e-paper-display-on-linux-without-raspberry-pi/og.webp tags: - linux - e-paper - kernel - spi aliases: - waveshare --- When it comes to e-ink displays, Waveshare is a rare manufacturer that allows you to buy displays of any size. Advertised as ESP32, Arduino and Raspberry Pi compatible, they are in fact compatible with any development board exposing the SPI protocol. Since Raspberry Pi boards have become hard to find in recent months, we'll take a look in this article at how to use another Linux-based board to run a Waveshare display. ## Display communication protocol Waveshare's e-ink displays are driven by the SPI protocol. Like USB or I²C, this is a common standard for communication between a machine and a peripheral. To communicate, the protocol uses 3 wires + 1 wire per peripheral: - `SCLK` (or `SCK` or `SCL`) is the clock signal to synchronize communication; - `MOSI` (or `SDO` or `SDA`) is the data flow from the machine to the peripheral; - `MISO` (or `SDI`) is the data flow from the peripheral to the machine. Several devices can share the SPI bus, the 3 wires described above. To keep track of who the information is intended for, there is an additional wire for each peripheral. With 3 devices on the SPI bus, we'll have 6 wires: the 3 bus wires, shared, and 1 wire per device, non-shared. This additional wire, called `CS` for Chip Select (or `SS`), is powered when the machine wishes to talk to the designated device. Only 1 device is active at a time, to avoid collisions on the bus (imagine two devices sending two separate messages at the same time... on the same wire). A device only talks and listens when its `CS` is low (at 0). When a device sends or receives data, it synchronizes to the clock frequency transmitted by the machine on the `SCLK` wire. At clock top, 1 bit of information can be read from `MISO` and `MOSI`. The machine and the peripheral can send data simultaneously, since we have 1 wire dedicated to transmission and 1 wire dedicated to reception. In the case of Waveshare displays, only uplink communication to the peripheral is used; the display will never respond on the SPI bus. There is therefore no wire for `MISO`. To communicate its status, it uses dedicated wires. ## Other wires used In addition to the power supply (+3.3 V and ground) and the wires used for the SPI protocol, 3 wires complete the communication: - `DC`: 0 when sending commands on the SPI bus. Set to 1 when sending data. - `RST`: when this wire is set to 0, it causes the screen to restart, resetting all its registers. It is reset to 1 to start using it. - `BUSY`: the chip controlling the screen uses this wire to indicate whether the screen or controller is busy, performing an action (when set to 0) or ready to receive information (when set to 1). With these specifications well established, there's nothing to limit us to the Raspberry Pi alone! ## Recognizing a compatible card To be used with the display, the board must expose an SPI bus on its GPIOs, and you must have 3 generic slots available in addition to 3.3 V and ground. All boards, recent and old, expose these interfaces. For example, [here's the pin assignment for the Pine64](https://files.pine64.org/doc/Pine%20A64%20Schematic/Pine%20A64%20Pin%20Assignment%20160119.pdf): ![Pin assignment Pi-2 on the Pine64-LTS](pine64-pi2-pinout.webp) And [for the Cubieboard](http://docs.cubieboard.org/cubieboard1_and_cubieboard2_gpio_pin) : ![Cubieboard U15 pin assignment](cubieboard-u15-pinout.webp) Some boards have GPIOs identical to those on the Raspberry Pi, so you can place the supplied HAT directly on them. When the pin organization is different, you'll have to cable it manually. I used a MIPS Creator CI 20 board: [![CI20 pin assignment](ci20-pinout.webp)](https://elinux.org/CI20_Hardware#Primary_expansion_header) Here is the wiring: ![My CI20 wired](ci20-attached.webp) ## Linux configuration The [Waveshare documentation](https://www.waveshare.com/wiki/7.5inch_e-Paper_HAT_Manual#Enable_SPI_Interface) suggests using the utilities supplied with Raspbian. We don't have the same simplified utilities, so we'll see how to do it directly. ### Activate SPI Usually, a kernel module is loaded to communicate with an SPI-connected device. This module will then create an abstraction layer and expose the abstraction in the `/dev` folder. Here, we don't have a kernel module dedicated to the screen; in fact, the program we're about to launch, the Waveshare demo, communicates directly via SPI. It's as if we'd written a program to read and write to a USB stick by sending the raw USB commands, rather than having the kernel display the device under `/dev/sdb`. In order to drive the SPI bus from a user-space program, we'll need to tell our kernel that it must use the `spidev` driver to manage the bus in question. If you've compiled your kernel yourself, make sure you have this module: ``` 42sh$ zgrep SPI_DEV /proc/config. gz CONFIG_SPI_SPIDEV=y ``` The kernel obtains information on the devices and modules to be used by consulting a file called *Device Tree*, specific to each SBC. Generally, when a device is not in use, it is marked as disabled in the *Device Tree*. If you don't have a `/dev/spidev*` file, you'll have to start by enabling it. ### Activation using a Device Tree Overlay The Raspbian tool that activates the SPI bus in the Waveshare documentation will in fact activate a pre-designed *Device Tree Overlay*. We can do the same by [creating our own *Device Tree Overlay*](https://bootlin.com/blog/using-device-tree-overlays-example-on-beaglebone-boards/). The aim is to activate our SPI bus. From the basic DTB file for our board, we should have somewhere (potentially in the `#include`) a block like this: ```c spi0: spi@10043000 { compatible = "ingenic,jz4780-spi"; reg = <0x10043000 0x1c>; #address-cells = <1>; #size-cells = <0>; interrupt-parent = <&intc>; interrupts = <8>; clocks = <&cgu JZ4780_CLK_SSI0>; clock-names = "spi"; dmas = <&dma JZ4780_DMA_SSI0_RX 0xffffffff>, <&dma JZ4780_DMA_SSI0_TX 0xffffffff>; dma-names = "rx", "tx"; status = "disabled"; }; ``` We can see that it is disabled (`status = "disabled"`). In relation to our pins, if these are indeed the ones we wish to use, we will then establish the following *overlay* (`my_spidev.dts`) : ```c /dts-v1/; /plugin/; &spi0 { status = "okay"; spidev0: spidev@0{ compatible = "spidev"; reg = <0>; #address-cells = <1>; #size-cells = <0>; spi-max-frequency = <500000>; }; } ``` Now let's compile our *overlay* : ``` dtc -O dtb -o MY_SPIDEV.dtbo my_spidev.dts ``` We then place it in our boot partition, alongside the kernel and our `dtb`. For example: ``` mv MY_SPIDEV.dtbo /boot/overlay/MY_SPIDEV.dtbo ``` Then reboot and load this *overlay* in u-boot: ``` # What is currently done load mmc 0:1 0x820000000 ci20.dtb fdt addr 0x82000000 # Steps to add after device tree loading load mmc 0:1 0x83000000 overlays/MY_SPIDEV.dtbo fdt resize 8192 fdt apply 0x83000000 # Then boot as usual bootz ... ``` Once we've tested that it works, we can add it to the startup script. ### Doing without an SPI bus If you don't have an SPI bus available, you can still use GPIO pins and dedicate them to this purpose. To do this, we'll use the `spi-gpio` kernel module, in addition to `spidev`. First of all, let's make sure we have its support: ``` 42sh$ zgrep SPI_GPIO /proc/config.gz CONFIG_SPI_GPIO=y ``` Here's the *overlay* we could write to simulate an SPI bus on pins 19, 21, 23, 24 and 26: ```c /dts-v1/; /plugin/; #include spi_gpio { compatible = "spi-gpio"; #address-cells = <1>; #size-cells = <0>; gpio-sck = <&gpf 15 GPIO_ACTIVE_HIGH>; /* PE15 */ gpio-miso = <&gpf 14 GPIO_ACTIVE_HIGH>; /* PE14 */ gpio-mosi = <&gpf 17 GPIO_ACTIVE_HIGH>; /* PE17 */ num-chipselects = <2>; cs-gpios = <&gpf 16 GPIO_ACTIVE_HIGH &gpf 18 GPIO_ACTIVE_HIGH>; /* PE16, PE18 */ spidev@0 { compatible = "spidev"; reg = <0>; spi-max-frequency = <1000000>; }; }; ``` The overlay is applied at startup, as described above. In both cases, it is also possible to make the changes directly in the original DTS file and replace the DTB used to boot the board. You'll have to do this again each time you update the kernel. ## Configuring dedicated GPIOs We've seen that the display uses 3 GPIOs in addition to the SPI bus. In my case, I chose to use pins 18 (`PF2`), 11 (`PD26`) and 22 (`PE8`). Let's declare them with: ```sh # BUSY: PF2 echo 162 > /sys/class/gpio/export # RST: PD26 echo 122 > /sys/class/gpio/export echo out > /sys/class/gpio/gpio122/direction # DC: PE8 echo 136 > /sys/class/gpio/export echo out > /sys/class/gpio/gpio136/direction ``` ## Adapting the demo code The Waveshare demo expects to run on a Raspberry Pi, so there are a number of hard-coded elements. In addition, it makes use of a Python dependency that doesn't work outside this platform. Here are the [implementation](https://git.nemunai.re/nemunaire/waveshareteam-e-Paper/commit/dfa74b004198794d01bdc461ea88d1cf9cdb80b5) I've made to adapt the code to my needs: ```diff --- a/RaspberryPi_JetsonNano/python/lib/waveshare_epd/epdconfig.py +++ b/RaspberryPi_JetsonNano/python/lib/waveshare_epd/epdconfig.py @@ -230,6 +230,89 @@ class SunriseX3: self.GPIO.cleanup([self.RST_PIN, self.DC_PIN, self.CS_PIN, self.BUSY_PIN], self.PWR_PIN) +class CreatorCI20: + # Pin definition + RST_PIN = 122 + DC_PIN = 136 + CS_PIN = 0 + BUSY_PIN = 162 + PWR_PIN = 18 + Flag = 0 + + def __init__(self): + import spidev + + self.SPI = spidev.SpiDev() + + def digital_write(self, pin, value): + if pin == 0: + return + + with open("/sys/class/gpio/gpio"+str(pin)+"/value", "w") as f: + f.write(str(value)) + + def digital_read(self, pin): + v = "0" + if pin != 0: + with open("/sys/class/gpio/gpio"+str(pin)+"/value") as f: + v = f.readline() + return int(v) + + def delay_ms(self, delaytime): + time.sleep(delaytime / 1000.0) + + def spi_writebyte(self, data): + self.SPI.writebytes(data) + + def spi_writebyte2(self, data): + # for i in range(len(data)): + # self.SPI.writebytes([data[i]]) + self.SPI.xfer3(data) + + def module_init(self): + if self.Flag == 0: + self.Flag = 1 + + # BUSY + with open("/sys/class/gpio/export", "w") as f: + f.write(str(self.BUSY_PIN)) + + # RST + with open("/sys/class/gpio/export", "w") as f: + f.write(str(self.RST_PIN)) + with open("/sys/class/gpio/gpio" + str(self.RST_PIN) + "/direction", "w") as f: + f.write("out") + + # DC + with open("/sys/class/gpio/export", "w") as f: + f.write(str(self.DC_PIN)) + with open("/sys/class/gpio/gpio" + str(self.DC_PIN) + "/direction", "w") as f: + f.write("out") + + # SPI device, bus = 0, device = 0 + self.SPI.open(32766, 0) + self.SPI.max_speed_hz = 4000000 + self.SPI.mode = 0b00 + return 0 + else: + return 0 + + def module_exit(self): + logger.debug("spi end") + self.SPI.close() + + logger.debug("close 5V, Module enters 0 power consumption ...") + self.Flag = 0 + + self.digital_write(self.RST_PIN, 0) + self.digital_write(self.DC_PIN, 0) + + # Clean up + for pin in [self.BUSY_PIN, self.RST_PIN, self.DC_PIN] + with open("/sys/class/gpio/unexport", "w") as f: + f.write(str(pin)) + + if os.path.exists('/sys/bus/platform/drivers/gpiomem-bcm2835'): implementation = RaspberryPi() elif os.path.exists('/sys/bus/platform/drivers/gpio-x3'): ``` ## Conclusion You can't expect a manufacturer to spend time documenting the use of its device for all existing platforms. We'd like to thank Waveshare for [posting very detailed specifications](https://www.waveshare.com/w/upload/6/60/7.5inch_e-Paper_V2_Specification.pdf) that enable advanced users to understand how the screen works and how to communicate with it. and how to communicate with it.