gdeh0213b73.py
Driver de l’écran e-paper 2,13 pouces monté sur la carte LilyGo T5 V2.3. 128 × 250 pixels en noir et blanc — et une image qui reste affichée même carte débranchée.
À quoi ça sert
L’e-paper n’affiche que du noir et du blanc, et met environ 2 secondes à se rafraîchir. En échange, il ne consomme rien pour maintenir l’image : une fois les micro-billes déplacées par le champ électrique, elles restent en place, alimentation coupée.
C’est le bon choix pour une étiquette, un panneau d’information, un badge d’atelier ou un capteur sur pile. C’est le mauvais choix pour tout ce qui bouge.
Câblage
Sur la LilyGo T5 V2.3, l’écran est déjà câblé sur la carte — ce tableau sert pour un module e-paper nu, ou pour vérifier.
| Signal | GPIO | Rôle |
|---|---|---|
| SCK | 18 | Horloge SPI |
| MOSI | 23 | Données ESP32 → écran |
| CS | 5 | Sélection du composant |
| DC | 17 | Commande (0) ou donnée (1) |
| RST | 16 | Reset |
| BUSY | 4 | L’écran signale qu’il travaille |
Il n’y a pas de MISO : l’écran ne renvoie jamais rien. C’est la broche BUSY qui joue ce rôle, en indiquant simplement quand le rafraîchissement est terminé.
API
EPD hérite de framebuf.FrameBuffer : toutes les méthodes de dessin de framebuf sont disponibles (fill, pixel, text, line, hline, vline, rect, fill_rect, scroll, blit).
| Appel | Effet |
|---|---|
| init_epd(rotation) | Construit l’objet. Rotation par défaut : ROTATION_0 |
| epd.width / .height | Dimensions après rotation |
| epd.fill(0 | 1) | 0 = blanc, 1 = noir |
| epd.text(s, x, y, 1) | Texte 8 × 8 pixels, ASCII uniquement |
| epd.update() | Envoie le tampon à la dalle (~2 s) |
| epd.deep_sleep() | Endort l’écran. L’image reste affichée |
Les quatre orientations : ROTATION_0 et ROTATION_180 donnent 128 × 250 (portrait), ROTATION_90 et ROTATION_270 donnent 250 × 128 (paysage).
Pièges connus
Symptôme. init_epd(rotation=ROTATION_90) lève un ValueError vide, à la construction du FrameBuffer :
File "gdeh0213b73.py", line 110, in __init__
ValueError:Cause. En MONO_HLSB, framebuf arrondit la largeur de ligne au multiple de 8 supérieur. En paysage, 250 pixels deviennent 256, soit 32 octets par ligne et non 31,25. Il faut donc 32 × 128 = 4096 octets, alors que le calcul d’origine width * height // 8 n’en réservait que 4000.
En portrait le problème n’existe pas, 128 étant déjà un multiple de 8 — d’où un bug qui n’apparaît que en paysage.
Correction (déjà appliquée dans le fichier du dépôt) :
size = ((self.__width + 7) // 8) * self.__heightSans effet sur le portrait : 4000 octets dans les deux cas.
La police intégrée de framebuf est en ASCII pur. é, è, à, ç sortent en caractères parasites ou en blanc.
Écris donc « Ardeche », « fenetre », « degres » — ou dessine ton propre texte à partir d’une police bitmap, ce qui est un tout autre chantier.
Le fichier contient bien une table LUT_PART_UPDATE, mais aucune méthode ne l’utilise. update() fait toujours un rafraîchissement complet, avec le clignotement noir/blanc caractéristique et ses ~2 secondes.
Pour une horloge ou un compteur qui change souvent, c’est une limite réelle. L’ajout d’un update_partial() reste à faire.
MicroPython garde en cache les modules déjà importés. Si tu recopies gdeh0213b73.py sur l’ESP32 sans faire Ctrl+D, c’est l’ancienne version qui continue de tourner — et tu obtiens exactement la même erreur qu’avant la correction.
Le réflexe : copier le fichier, puis Ctrl+D, puis relancer.
Code minimal
from gdeh0213b73 import init_epd, ROTATION_90
epd = init_epd(rotation=ROTATION_90) # 250 x 128 paysage
epd.fill(0) # fond blanc
epd.text("Bonjour", 10, 10, 1) # texte noir
epd.rect(0, 0, epd.width, epd.height, 1)
epd.update() # ~2 secondes
epd.deep_sleep() # l'image reste afficheeExemple complet
Affichage centré sur plusieurs tailles de texte, avec une fonction texte_centre() qui recalcule la position horizontale toute seule.
payzac_fablab.py — affichage centré .python cours-exemples/epaper/payzac_fablab.py 84 lignes
GitHub
# payzac_fablab.py — LilyGo T5 V2.3 — Affiche "Payzac et son FabLab"
# -----------------------------------------------------------------------
# Bibliothèque : gdeh0213b73.py (à copier sur l'ESP32)
# fill(0) = blanc fill(1) = noir
#
# Écran 2.13" : 128 × 250 en portrait, 250 × 128 en paysage.
# Ici on travaille en PAYSAGE (ROTATION_90) : plus large, donc plus
# confortable pour du texte.
#
# ATTENTION : la police intégrée de framebuf est en ASCII pur.
# Les accents (é, è, à...) ne s'affichent pas correctement.
# D'où "Ardeche" sans accent dans la ligne du bas.
# -----------------------------------------------------------------------
from gdeh0213b73 import init_epd, ROTATION_90
import framebuf
def big_text(epd, s, x, y, scale=2, c=1):
"""Texte agrandi : scale=1 → 8px, scale=2 → 16px, scale=3 → 24px..."""
w = len(s) * 8
bpr = (w + 7) // 8
buf = bytearray(bpr * 8)
fb = framebuf.FrameBuffer(buf, w, 8, framebuf.MONO_HLSB)
fb.fill(0)
fb.text(s, 0, 0, 1)
for ty in range(8):
for tx in range(w):
if fb.pixel(tx, ty):
epd.fill_rect(x + tx * scale, y + ty * scale, scale, scale, c)
def texte_centre(epd, s, y, scale=2, c=1):
"""Comme big_text, mais calcule x tout seul pour centrer le texte."""
largeur = len(s) * 8 * scale # largeur totale en pixels
x = (epd.width - largeur) // 2 # marge égale à gauche et à droite
big_text(epd, s, x, y, scale, c)
return x
print("Initialisation de l'ecran...")
epd = init_epd(rotation=ROTATION_90) # 250 × 128 paysage
epd.fill(0) # fond blanc
# --- Cadre extérieur (double trait) ---
epd.rect(0, 0, epd.width, epd.height, 1)
epd.rect(3, 3, epd.width - 6, epd.height - 6, 1)
# --- Le texte, centré ---
texte_centre(epd, "PAYZAC", 16, scale=4) # 32 px de haut
texte_centre(epd, "et son", 54, scale=2) # 16 px
texte_centre(epd, "FabLab", 76, scale=4) # 32 px
# --- Filets décoratifs de part et d'autre de "et son" ---
epd.hline(28, 61, 44, 1)
epd.hline(178, 61, 44, 1)
# --- Ligne du bas ---
texte_centre(epd, "Ardeche . MicroPython . ESP32", 114, scale=1)
print("Affichage en cours... (~2 s)")
epd.update() # rafraîchissement de la dalle
epd.deep_sleep() # l'image RESTE affichée, même hors tension
print("OK ! L'image reste affichee sans alimentation.")
# -----------------------------------------------------------------------
# EXPÉRIENCES À TESTER :
# - Remplace "PAYZAC" par ton prénom : texte_centre garde le centrage
# - Passe scale=4 à scale=3 → texte plus petit, plus de place
# - Enlève epd.deep_sleep() : l'image reste affichée quand même,
# mais la carte consomme beaucoup plus
# - Supprime le second epd.rect() → cadre simple au lieu de double
# - Inverse la vidéo : epd.fill(1) puis passe c=0 dans chaque appel
# → texte blanc sur fond noir
#
# À RETENIR — pourquoi un écran e-paper :
# L'image est formée par de vraies micro-billes noires et blanches
# déplacées par un champ électrique. Une fois en place, elles ne
# bougent plus : l'affichage ne consomme RIEN pour être maintenu.
# D'où les liseuses qui tiennent des semaines sur une charge.
# En contrepartie, le rafraîchissement prend environ 2 secondes.
# -----------------------------------------------------------------------
- Remplace
"PAYZAC"par ton prénom :texte_centre()garde le centrage tout seul - Passe
scale=4àscale=3→ texte plus petit, plus de place - Inverse la vidéo :
epd.fill(1)puisc=0dans chaque appel → blanc sur noir - Débranche la carte après l’affichage : l’image est toujours là
- Plus dur : affiche la date du prochain atelier, récupérée en WiFi
Le fichier source
gdeh0213b73.py — le driver complet .python cours-exemples/epaper/gdeh0213b73.py 229 lignes
GitHub
"""
Driver MicroPython pour ecran e-paper LilyGo T5 V2.3 - 2.13 pouces
Base sur le driver officiel LilyGo gdeh0213b73.py
- fill(0) = blanc
- fill(1) = noir
- text("Bonjour", x, y, 1) = texte noir
"""
from micropython import const
from time import sleep_ms
from machine import Pin, SPI
import framebuf
ROTATION_0 = const(0)
ROTATION_90 = const(1)
ROTATION_180 = const(2)
ROTATION_270 = const(3)
EPD_WIDTH = const(128)
EPD_HEIGHT = const(250)
LUT_FULL_UPDATE = memoryview(bytes([
0xA0, 0x90, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x50, 0x90, 0xA0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xA0, 0x90, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x50, 0x90, 0xA0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x0F, 0x0F, 0x00, 0x00, 0x00,
0x0F, 0x0F, 0x00, 0x00, 0x03,
0x0F, 0x0F, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x17, 0x41, 0xA8, 0x32, 0x50, 0x0A, 0x09,
]))
LUT_PART_UPDATE = memoryview(bytes([
0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x0A, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
0x15, 0x41, 0xA8, 0x32, 0x50, 0x2C, 0x0B,
]))
SW_RESET = b'\x12'
DRIVER_OUTPUT_CONTROL = b'\x01'
GATE_DRIVING_VOLTAGE_CONTROL = b'\x03'
SOURCE_DRIVING_VOLTAGE_CONTROL = b'\x04'
DATA_ENTRY_MODE_SETTING = b'\x11'
MASTER_ACTIVATION = b'\x20'
DISPLAY_UPDATE_CONTROL_1 = b'\x21'
DISPLAY_UPDATE_CONTROL_2 = b'\x22'
WRITE_RAM = b'\x24'
WRITE_VCOM_REGISTER = b'\x2C'
WRITE_LUT_REGISTER = b'\x32'
SET_DUMMY_LINE_PERIOD = b'\x3A'
SET_GATE_TIME = b'\x3B'
BORDER_WAVEFORM_CONTROL = b'\x3C'
SET_RAM_X_ADDRESS_START_END = b'\x44'
SET_RAM_Y_ADDRESS_START_END = b'\x45'
SET_RAM_X_ADDRESS_COUNTER = b'\x4E'
SET_RAM_Y_ADDRESS_COUNTER = b'\x4F'
SET_ANALOG_BLOCK_CONTROL = b'\x74'
SET_DIGITAL_BLOCK_CONTROL = b'\x7E'
DEEP_SLEEP_MODE = b'\x10'
TERMINATE_FRAME_READ_WRITE = b'\xFF'
class EPD(framebuf.FrameBuffer):
def __init__(self, spi, cs, dc, rst, busy, rotation=ROTATION_0):
self.spi = spi
self.cs = cs
self.dc = dc
self.rst = rst
self.busy = busy
self.spi.init()
self.cs.init(self.cs.OUT, value=1)
self.dc.init(self.dc.OUT, value=0)
self.rst.init(self.rst.OUT, value=0)
self.busy.init(self.busy.IN, value=0)
self.__rotation = rotation
if rotation in (ROTATION_0, ROTATION_180):
self.__width = EPD_WIDTH
self.__height = EPD_HEIGHT
else:
self.__width = EPD_HEIGHT
self.__height = EPD_WIDTH
# Nombre d'octets par ligne : framebuf arrondit toujours la largeur
# au multiple de 8 superieur. En paysage (250 px), une ligne occupe
# donc 32 octets et non 31,25 — sans cet arrondi le tampon est trop
# petit et framebuf leve un ValueError sans message.
size = ((self.__width + 7) // 8) * self.__height
self.buffer = memoryview(bytearray(size))
super().__init__(self.buffer, self.__width, self.__height, framebuf.MONO_HLSB)
self.hard_reset()
@property
def width(self):
return self.__width
@property
def height(self):
return self.__height
def _command(self, command, data=None):
self.cs(1); self.dc(0); self.cs(0)
self.spi.write(command)
self.cs(1)
if data is not None:
self._data(data)
def _data(self, data):
self.cs(1); self.dc(1); self.cs(0)
self.spi.write(data)
self.cs(1)
def _wait_until_idle(self):
while self.busy.value() == 1:
sleep_ms(10)
def hard_reset(self):
self.rst(1); sleep_ms(1)
self.rst(0); sleep_ms(10)
self.rst(1)
def _init(self):
self._wait_until_idle()
self._command(SW_RESET)
self._wait_until_idle()
self._command(SET_ANALOG_BLOCK_CONTROL, b'\x54')
self._command(SET_DIGITAL_BLOCK_CONTROL, b'\x3B')
self._command(DRIVER_OUTPUT_CONTROL, b'\xF9\x00\x00')
self._command(DATA_ENTRY_MODE_SETTING, b'\x03')
self._command(BORDER_WAVEFORM_CONTROL, b'\x05')
self._command(WRITE_VCOM_REGISTER, b'\x50')
self._command(GATE_DRIVING_VOLTAGE_CONTROL, LUT_FULL_UPDATE[100:101])
self._command(SOURCE_DRIVING_VOLTAGE_CONTROL, LUT_FULL_UPDATE[101:103])
self._command(SET_DUMMY_LINE_PERIOD, LUT_FULL_UPDATE[105:106])
self._command(SET_GATE_TIME, LUT_FULL_UPDATE[106:107])
self._command(WRITE_LUT_REGISTER, LUT_FULL_UPDATE[0:100])
self._command(DISPLAY_UPDATE_CONTROL_1, b'\x08')
self._wait_until_idle()
def _init_full(self):
self._init()
self._command(WRITE_LUT_REGISTER, LUT_FULL_UPDATE)
self._command(DISPLAY_UPDATE_CONTROL_2, b'\xC0')
self._command(MASTER_ACTIVATION)
self._wait_until_idle()
def _set_ram_area(self):
self._command(SET_RAM_X_ADDRESS_START_END, b'\x00\x0F')
self._command(SET_RAM_Y_ADDRESS_START_END, b'\x00\x00\xF9\x00')
self._command(SET_RAM_X_ADDRESS_COUNTER, b'\x00')
self._command(SET_RAM_Y_ADDRESS_COUNTER, b'\x00\x00')
self._wait_until_idle()
def _get_rotated_buffer(self):
if self.__rotation == ROTATION_0:
return self.buffer
size = EPD_WIDTH * EPD_HEIGHT // 8
fbuf = memoryview(bytearray(size))
frame = framebuf.FrameBuffer(fbuf, EPD_WIDTH, EPD_HEIGHT, framebuf.MONO_HLSB)
if self.__rotation == ROTATION_270:
for x in range(self.__width):
for y in range(self.__height):
frame.pixel(y, EPD_HEIGHT - x - 1, self.pixel(x, y))
elif self.__rotation == ROTATION_90:
for x in range(self.__width):
for y in range(self.__height):
frame.pixel(EPD_WIDTH - y - 1, x, self.pixel(x, y))
frame.scroll(-6, 0)
elif self.__rotation == ROTATION_180:
for i in range(size):
fbuf[size - i - 1] = self.buffer[i]
frame.scroll(-6, 0)
return fbuf
def update(self):
self._init_full()
self._set_ram_area()
self._command(WRITE_RAM, self._get_rotated_buffer())
self._command(DISPLAY_UPDATE_CONTROL_2, b'\xC7')
self._command(MASTER_ACTIVATION)
self._wait_until_idle()
self._command(DISPLAY_UPDATE_CONTROL_2, b'\xC3')
self._command(MASTER_ACTIVATION)
self._wait_until_idle()
def deep_sleep(self):
self._command(DEEP_SLEEP_MODE, b'\x01')
def init_epd(rotation=ROTATION_0):
"""
fill(0) = blanc
fill(1) = noir
text("Bonjour", x, y, 1) = texte noir sur blanc
"""
spi = SPI(1, baudrate=4000000,
sck=Pin(18),
mosi=Pin(23))
return EPD(spi,
cs=Pin(5),
dc=Pin(17),
rst=Pin(16),
busy=Pin(4),
rotation=rotation)