Utiliser les écrans à encre électronique Waveshare sans Raspberry Pi

En termes d’écrans à encre électronique (e-ink), Waveshare est un des rares constructeurs permettant d’acheter des écrans de toute taille. Annoncés compatibles ESP32, Arduino et Raspberry Pi, ils sont en fait compatibles avec n’importe quelle carte de développement exposant le protocole SPI.

Les Raspberry Pi était devenues difficiles à trouver ces derniers mois, nous allons voir dans cet article comment utiliser une autre carte sous Linux pour utiliser un écran Waveshare.

Le protocole de communication de l’écran

Les écrans à encre électronique de Waveshare sont pilotés par le protocole SPI. Au même titre que l’USB ou l’I²C, il s’agit d’un standard courant de communication entre une machine et un périphérique.

Pour communiquer, le protocole utilise 3 fils + 1 fil par périphérique :

  • SCLK (ou SCK ou SCL) est le signal d’horloge pour synchroniser la communication ;
  • MOSI (ou SDO ou SDA) est le flux de données allant de la machine vers le périphérique ;
  • MISO (ou SDI) est le flux de données allant du périphérique vers la machine.

Plusieurs périphériques peuvent partager le bus SPI, les 3 fils décrits ci-dessus. Pour savoir à qui sont destinées les informations, il y a donc un fil supplémentaire pour chaque périphérique. Avec 3 périphériques sur le bus SPI, on aura donc 6 fils : les 3 fils du bus, partagés, et 1 fil par périphérique, non-partagé.

Ce fil supplémentaire, nommé CS pour Chip Select (ou SS), est alimenté lorsque la machine souhaite parler au périphérique désigné. 1 seul périphérique est actif à la fois pour éviter les collisions sur le bus (imaginer deux périphériques envoyer deux messages distincts en même temps … sur le même fil). Un périphérique ne parle et n’écoute que lorsque son CS est à l’état bas (à 0).

Lorsqu’un périphérique envoie ou reçoit des données, il se synchronise sur la fréquence d’horloge transmise par la machine sur le fil SCLK. Au top d’horloge, on peut lire 1 bit d’information sur MISO et MOSI.

La machine et le périphérique peuvent envoyer simultanément des données puisque nous avons 1 fil dédié à l’émission et 1 fil dédié à la réception. Dans le cas des écrans Waveshare, seule la communication montante vers le périphérique est utilisée, l’écran ne répondra jamais sur le bus SPI. Il n’y a donc pas de fil pour MISO. Pour communiquer son statut, il utilise des fils dédiés.

Les autres fils utilisés

En plus de l’alimentation électrique (+3.3 V et la masse) et des fils utilisés pour le protocole SPI, 3 fils viennent compléter la communication :

  • DC : à 0 lorsque l’on enverra des commandes sur le bus SPI. À 1 lorsque ce sera des données qui y seront envoyées.
  • RST : lorsque ce fil est à 0, cela provoque le redémarrage de l’écran, la réinitialisation de tous ses registres. On le replace à 1 pour commencer à l’utiliser.
  • BUSY : la puce contrôlant l’écran utilise ce fil pour indiquer si l’écran ou le contrôleur est occupé, en train de réaliser une action (lorsqu’il est à 0) ou s’il est prêt à recevoir des informations (il est alors à 1) .

Ces spécifications étant bien établies, rien ne nous limite aux seules Raspberry Pi !

Reconnaître une carte compatible

Pour être utilisée avec l’écran, la carte doit exposer un bus SPI sur ses GPIO, et vous devez disposer de 3 emplacements génériques disponibles en plus du 3,3 V et de la masse.

Toutes les cartes, récentes et anciennes, exposent ces interfaces.

Voici par exemple la correspondance des broches de la Pine64 :

Correspondance des broches Pi-2 de la Pine64-LTS

Et ceux de la Cubieboard :

Correspondance des broches U15 de la Cubieboard

Certaines cartes exposent des GPIO identiques à ceux de la Raspberry Pi, vous pourrez donc placer le HAT fourni directement dessus. Lorsque l’organisation des broches est différente, il faudra câbler manuellement.

Pour ma part, j’ai utilisé une carte MIPS Creator CI 20 :

Correspondance des broches de la CI20

Et voici donc le câblage :

La CI20 câblée

Configuration Linux

La documentation de Waveshare propose d’utiliser les utilitaires livrés avec Raspbian. Nous n’avons pas les mêmes utilitaires simplifiés, nous allons donc voir comment faire directement.

Activer le SPI

Habituellement, on charge un module noyau pour communiquer avec un appareil relié en SPI. Ce module créera alors une couche d’abstraction et exposera l’abstraction dans le dossier /dev.

Ici, nous n’avons pas de module noyau dédié à l’écran, en fait le programme que l’on va lancer, la démo de Waveshare, communique directement en SPI. C’est comme si on avait écrit un programme pour lire et écrire sur une clef USB en envoyant les commandes USB brutes, plutôt que de faire en sorte que le noyau affiche le périphérique sous /dev/sdb.

Afin de piloter le bus SPI depuis un programme de l’espace utilisateur, on va devoir indiquer à notre noyau qu’il doit utiliser le pilote spidev pour gérer le bus en question.

Si vous avez compilé vous-même votre noyau, assurez-vous d’avoir ce module :

42sh$ zgrep SPI_DEV /proc/config.gz
CONFIG_SPI_SPIDEV=y

Le noyau obtient les informations sur les périphériques et les modules à utiliser en consultant un fichier dit Device Tree, propre à chaque SBC.

Généralement, lorsqu’un périphérique n’est pas utilisé, il est marqué comme désactivé dans le Device Tree. Si vous n’avez pas de fichier /dev/spidev*, il va donc falloir commencer par l’activer.

Activation par Device Tree Overlay

L’outil de Raspbian qui active le bus SPI dans la documentation de Waveshare va en fait activer un Device Tree Overlay préconçu.

Nous pouvons faire de même en créant notre propre Device Tree Overlay. Le but étant d’activer notre bus SPI.

À partir du fichier DTB de base pour notre carte, nous devrions avoir quelque part (potentiellement dans les #include) un bloc comme celui-ci :

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";
};

On constate qu’il est bien désactivé (status = "disabled").

Par rapport à nos broches, s’il s’agit bien de celles que l’on souhaite utiliser, on va alors établir l'overlay suivant (my_spidev.dts) :

/dts-v1/;
/plugin/;

&spi0 {
    status = "okay";

    spidev0: spidev@0{
		compatible = "spidev";
		reg = <0>;
		#address-cells = <1>;
		#size-cells = <0>;
		spi-max-frequency = <500000>;
	};
}

Compilons maintenant notre overlay :

dtc -O dtb -o MY_SPIDEV.dtbo my_spidev.dts

On le place ensuite dans notre partition de démarrage, aux côtés du noyau et de notre dtb. Par exemple :

mv MY_SPIDEV.dtbo /boot/overlay/MY_SPIDEV.dtbo

Il faut ensuite redémarrer et dans u-boot, charger cet overlay :

# 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 ...

Une fois que l’on aura testé que cela fonctionne, on pourra l’ajouter au script de démarrage.

Faire sans bus SPI

Si vous n’avez pas de bus SPI disponible, il reste possible d’utiliser des broches GPIO et de les dédier à cet usage. On utilisera pour cela le module noyau spi-gpio, en plus de spidev.

Vérifions d’abord qu’on dispose bien de son support :

42sh$ zgrep SPI_GPIO /proc/config.gz
CONFIG_SPI_GPIO=y

Voici l'overlay que l’on pourrait écrire pour simuler un bus SPI sur les broches 19, 21, 23, 24 et 26 :

/dts-v1/;
/plugin/;

#include <dt-bindings/gpio/gpio.h>

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>;
    };
};

On applique l’overlay au démarrage, comme vu précédemment.

Dans les deux cas, il est aussi possible de faire les modifications directement dans le fichier DTS original et de remplacer le DTB utiliser pour démarrer la carte. Il faudra alors veiller à refaire cela à chaque mise à jour du noyau.

Configuration des GPIO dédiés

On avait vu que l’écran utilisait 3 GPIO en plus du bus SPI.

Dans mon cas, j’ai choisi d’utiliser les broches 18 (PF2), 11 (PD26) et 22 (PE8). Déclarons-les avec :

# 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

Adapter le code de la démo

La démo de Waveshare s’attend à être exécutée sur un Raspberry Pi et on retrouve donc un certain nombre d’éléments écrits en dur. De plus, elle fait usage d’une dépendance Python qui ne fonctionne pas en dehors de cette plateforme.

Voici l’implémentation que j’ai effectué afin d’adapter le code à mon usage :

--- 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

On ne peut pas s’attendre à ce qu’un constructeur passe du temps à documenter l’usage de son périphérique pour toutes les plateformes existantes. On remerciera Waveshare pour avoir mis en ligne des spécifications très détaillées qui permettent aux utilisateurs avancés de comprendre le fonctionnement de l’écran et la manière de communiquer avec lui.