# pantalla.py - Driver para LCMEN2R13EFC1 (Heltec Vision Master E213)
# Usa SPI(2) con los pines correctos del header J2
"""
Driver de bajo nivel para el panel e-paper **LCMEN2R13EFC1**
(Heltec Vision Master E213, 2.13", 122×250 px útiles).
El módulo encapsula:
* La configuración del bus :class:`machine.SPI` (SPI3 del ESP32-S3) y de
los pines de control del *header* J2.
* La carga de las LUTs (tablas de formas de onda) requeridas por el
controlador para las transiciones blanco→negro, negro→blanco, etc.
* Un framebuffer en RAM de ``128 × 250 / 8 = 4000`` bytes que se envía
al panel en cada refresco.
* Rutinas de dibujo elementales (píxel, líneas, texto con fuentes
8×8 y 12×16).
El panel se maneja en dos orientaciones lógicas:
* ``orientacion = 0`` → 128×250 (retrato, *nativo*).
* ``orientacion = 90`` → 250×128 (apaisado, rotación por software).
.. note::
El refresco es **bloqueante**. La función :meth:`Pantalla.actualizar`
espera al pin ``BUSY`` del panel, por lo que puede tardar varios
segundos (típicamente ~2 s en modo completo).
.. warning::
Este driver asume que los módulos :mod:`fuente_8x8` y
:mod:`fuente_12x16` están disponibles en el *frozen* o en el sistema
de archivos del microcontrolador, y que exponen respectivamente los
diccionarios ``PATRONES`` y ``PATRONES_12x16``.
:copyright: (c) 2024
:license: MIT
"""
from machine import Pin, SPI
import time
from fuente_8x8 import PATRONES as F8
from fuente_12x16 import PATRONES_12x16 as F16
# ===== HARDWARE (según datasheet HT-VME213, header J2) =====
PIN_DC = 2
"""int: Pin GPIO conectado al terminal *Data/Command* del panel."""
PIN_CS = 5
"""int: Pin GPIO de *Chip Select* (activo en bajo)."""
PIN_BUSY = 1
"""int: Pin GPIO de entrada que indica ``1`` = libre, ``0`` = ocupado."""
PIN_RST = 3
"""int: Pin GPIO de *reset* por hardware (activo en bajo)."""
PIN_VEXT = 18
"""int: Pin GPIO que habilita la alimentación externa (VEXT) del panel."""
PIN_SCK = 4
"""int: Pin GPIO de reloj SPI (SCLK)."""
PIN_MOSI = 6
"""int: Pin GPIO de datos SPI (MOSI)."""
PIN_MISO = -1
"""int: Pin GPIO de MISO. ``-1`` indica que no se usa."""
ANCHO_FISICO = 128
"""int: Ancho físico del controlador en píxeles (el panel usa 122)."""
ALTO_FISICO = 250
"""int: Alto físico del panel en píxeles."""
NEGRO = 1
"""int: Valor lógico para píxel encendido (tinta negra)."""
BLANCO = 0
"""int: Valor lógico para píxel apagado (fondo blanco)."""
[documentos]
class Pantalla:
"""
Driver del panel e-paper LCMEN2R13EFC1.
:param orientacion: ``0`` para retrato (128×250) o ``90`` para
apaisado (250×128). Cualquier otro valor lanza
:class:`ValueError`.
:type orientacion: int
:ivar orientacion: Orientación lógica actual.
:vartype orientacion: int
:ivar ancho: Ancho lógico en píxeles (depende de la orientación).
:vartype ancho: int
:ivar alto: Alto lógico en píxeles (depende de la orientación).
:vartype alto: int
:ivar buffer: Framebuffer en formato *1 bit por píxel*, con
``ANCHO_FISICO // 8`` bytes por fila.
:vartype buffer: bytearray
:ivar inicializado: ``True`` si el panel ya recibió la secuencia de
inicialización y aún no se ha dormido.
:vartype inicializado: bool
"""
[documentos]
def __init__(self, orientacion=90):
"""
Construye el objeto :class:`Pantalla`, configura el bus SPI y
reserva el framebuffer. **No** inicializa el panel; eso ocurre
de forma perezosa al llamar a :meth:`inicializar` o
:meth:`actualizar`.
:param orientacion: ``0`` (retrato) o ``90`` (apaisado).
:type orientacion: int
:raises ValueError: Si ``orientacion`` no es ``0`` ni ``90``.
"""
self.orientacion = orientacion
# Pines de control
self.dc = Pin(PIN_DC, Pin.OUT)
self.cs = Pin(PIN_CS, Pin.OUT)
self.busy = Pin(PIN_BUSY, Pin.IN)
self.rst = Pin(PIN_RST, Pin.OUT)
self.vext = Pin(PIN_VEXT, Pin.OUT)
# SPI(2) con pines explícitos (SPI3 hardware del ESP32-S3)
self.spi = SPI(
2,
baudrate=4_000_000,
polarity=0,
phase=0,
bits=8,
firstbit=SPI.MSB,
sck=Pin(PIN_SCK),
mosi=Pin(PIN_MOSI),
miso=Pin(PIN_MISO) if PIN_MISO >= 0 else None,
)
# Buffer: 128 píxeles de ancho / 8 = 16 bytes por fila × 250 filas
self.bytes_por_fila = ANCHO_FISICO // 8
self.buffer_size = self.bytes_por_fila * ALTO_FISICO
self.buffer = bytearray(self.buffer_size)
# Dimensiones lógicas
if orientacion == 0:
self.ancho, self.alto = ANCHO_FISICO, ALTO_FISICO
elif orientacion == 90:
self.ancho, self.alto = ALTO_FISICO, ANCHO_FISICO
else:
raise ValueError("Orientación debe ser 0 o 90")
self._buf1 = bytearray(1)
self.inicializado = False
# ------------------------------------------------------------------
# Bajo nivel
# ------------------------------------------------------------------
def _cmd(self, c):
"""
Envía un único byte como **comando** al panel (``DC = 0``).
:param c: Byte de comando (0x00–0xFF).
:type c: int
"""
self._buf1[0] = c
self.dc.value(0); self.cs.value(0)
self.spi.write(self._buf1)
self.cs.value(1)
def _data(self, d):
"""
Envía un único byte como **dato** al panel (``DC = 1``).
:param d: Byte de dato (0x00–0xFF).
:type d: int
"""
self._buf1[0] = d
self.dc.value(1); self.cs.value(0)
self.spi.write(self._buf1)
self.cs.value(1)
def _esperar(self, timeout_ms=5000, etiqueta=""):
"""
Bloquea hasta que el pin ``BUSY`` pase a ``1`` (panel libre) o
se agote el *timeout*.
:param timeout_ms: Tiempo máximo de espera en milisegundos.
:type timeout_ms: int
:param etiqueta: Texto identificatorio usado en el mensaje de
*timeout* (útil para depuración).
:type etiqueta: str
:return: ``True`` si el panel quedó libre; ``False`` si hubo
*timeout*.
:rtype: bool
"""
t0 = time.ticks_ms()
while self.busy.value() == 0:
if time.ticks_diff(time.ticks_ms(), t0) > timeout_ms:
print(f"[PAN] TIMEOUT {etiqueta}")
return False
time.sleep_ms(20)
return True
# ------------------------------------------------------------------
# Inicialización
# ------------------------------------------------------------------
[documentos]
def inicializar(self):
"""
Ejecuta la secuencia de arranque del panel: habilita ``VEXT``,
aplica un *reset* por hardware, espera a que ``BUSY`` se libere
y carga las LUTs.
Es **idempotente**: si :attr:`inicializado` ya es ``True``,
retorna sin hacer nada.
"""
if self.inicializado:
return
print("[PAN] Inicializando...")
self.vext.value(1)
time.sleep_ms(100)
self.rst.value(0); time.sleep_ms(20)
self.rst.value(1); time.sleep_ms(100)
self._esperar(2000, "reset")
self._cmd(0x00); self._data(0x0F) # PSR
self._cmd(0x50); self._data(0x97) # CDI
self._cargar_luts()
self.inicializado = True
print("[PAN] OK")
def _cargar_luts(self):
"""
Carga las cinco tablas de formas de onda (*Look-Up Tables*)
requeridas por el controlador:
* ``0x20`` — VCOM
* ``0x21`` — WW (blanco → blanco)
* ``0x22`` — BW (negro → blanco)
* ``0x23`` — WB (blanco → negro)
* ``0x24`` — BB (negro → negro)
Cada tabla se rellena con ceros hasta completar los 56 bytes
esperados por el panel.
"""
LUT_VCOM = [0x01,0x06,0x03,0x02,0x01,0x01,0x01,
0x01,0x06,0x02,0x01,0x01,0x01,0x01] + [0x00]*42
LUT_WW = [0x01,0x06,0x03,0x02,0x81,0x01,0x01,
0x01,0x06,0x02,0x01,0x01,0x01,0x01] + [0x00]*42
LUT_BW = [0x01,0x86,0x83,0x82,0x81,0x01,0x01,
0x01,0x86,0x82,0x01,0x01,0x01,0x01] + [0x00]*42
LUT_WB = [0x01,0x46,0x43,0x02,0x01,0x01,0x01,
0x01,0x46,0x42,0x01,0x01,0x01,0x01] + [0x00]*42
LUT_BB = [0x01,0x06,0x03,0x42,0x41,0x01,0x01,
0x01,0x06,0x02,0x01,0x01,0x01,0x01] + [0x00]*42
for cmd, tabla in ((0x20,LUT_VCOM),(0x21,LUT_WW),
(0x22,LUT_BW),(0x23,LUT_WB),(0x24,LUT_BB)):
self._cmd(cmd)
for v in tabla:
self._data(v)
# ------------------------------------------------------------------
# Dibujo
# ------------------------------------------------------------------
[documentos]
def pixel(self, x, y, color=NEGRO):
"""
Escribe un píxel en el framebuffer aplicando la rotación
correspondiente a :attr:`orientacion`.
Las coordenadas fuera del área lógica se ignoran silenciosamente.
:param x: Coordenada horizontal lógica (0 … ``ancho-1``).
:type x: int
:param y: Coordenada vertical lógica (0 … ``alto-1``).
:type y: int
:param color: :data:`NEGRO` o :data:`BLANCO`.
:type color: int
"""
if x < 0 or y < 0 or x >= self.ancho or y >= self.alto:
return
if self.orientacion == 0:
xf, yf = x, y
else:
xf = y
yf = ALTO_FISICO - 1 - x
pos = yf * self.bytes_por_fila + (xf >> 3)
mask = 1 << (7 - (xf & 7))
if color:
self.buffer[pos] |= mask
else:
self.buffer[pos] &= ~mask
[documentos]
def limpiar(self, color=BLANCO):
"""
Rellena todo el framebuffer con un color uniforme.
:param color: :data:`BLANCO` (por defecto) o :data:`NEGRO`.
:type color: int
"""
v = 0xFF if color else 0x00
self.buffer[:] = bytes([v]) * self.buffer_size
[documentos]
def linea_h(self, x, y, largo, color=NEGRO):
"""
Dibuja una línea horizontal.
:param x: Coordenada X del extremo izquierdo.
:type x: int
:param y: Coordenada Y de la línea.
:type y: int
:param largo: Cantidad de píxeles a dibujar hacia la derecha.
:type largo: int
:param color: :data:`NEGRO` o :data:`BLANCO`.
:type color: int
"""
for i in range(largo):
self.pixel(x + i, y, color)
[documentos]
def linea_v(self, x, y, largo, color=NEGRO):
"""
Dibuja una línea vertical.
:param x: Coordenada X de la línea.
:type x: int
:param y: Coordenada Y del extremo superior.
:type y: int
:param largo: Cantidad de píxeles a dibujar hacia abajo.
:type largo: int
:param color: :data:`NEGRO` o :data:`BLANCO`.
:type color: int
"""
for i in range(largo):
self.pixel(x, y + i, color)
[documentos]
def texto(self, txt, x, y, color=NEGRO, tamaño=8):
"""
Escribe una cadena usando las fuentes incluidas.
:param txt: Texto a dibujar. Se admiten únicamente los
caracteres presentes en la tabla correspondiente.
:type txt: str
:param x: Coordenada X del extremo izquierdo.
:type x: int
:param y: Coordenada Y de la línea base superior.
:type y: int
:param color: :data:`NEGRO` o :data:`BLANCO`.
:type color: int
:param tamaño: ``8`` para la fuente 8×8, ``16`` para la 12×16.
:type tamaño: int
"""
if tamaño == 8:
for c in txt:
self._char(c, x, y, color, F8, 8)
x += 8
elif tamaño == 16:
for c in txt:
self._char(c, x, y, color, F16, 12)
x += 12
def _char(self, c, x, y, color, tabla, ancho):
"""
Renderiza un único carácter a partir de su patrón de bits.
:param c: Carácter a dibujar.
:type c: str
:param x: Coordenada X del extremo izquierdo del glifo.
:type x: int
:param y: Coordenada Y superior del glifo.
:type y: int
:param color: Color con el que se pintan los bits activos.
:type color: int
:param tabla: Diccionario ``{carácter: [filas]}`` con los
patrones de bits (MSB a la izquierda).
:type tabla: dict
:param ancho: Ancho del glifo en píxeles (``8`` o ``12``).
:type ancho: int
"""
p = tabla.get(c)
if not p:
return
alto = len(p)
for fy, fila in enumerate(p):
for cx in range(ancho):
if fila & (1 << (15 - cx if ancho == 12 else 7 - cx)):
self.pixel(x + cx, y + fy, color)
# ------------------------------------------------------------------
# Refresco
# ------------------------------------------------------------------
[documentos]
def actualizar(self):
"""
Envía el framebuffer al panel y dispara un refresco completo.
La secuencia es:
1. ``PON`` (0x04) — enciende la fuente de alimentación interna.
2. ``DTM1`` (0x10) — envía los datos *antiguos* (todo blanco).
3. ``DTM2`` (0x13) — envía el framebuffer actual.
4. ``DRF`` (0x12) — ejecuta el refresco (bloqueante).
5. ``POF`` (0x02) — apaga la fuente de alimentación.
Al finalizar, :attr:`inicializado` se restablece a ``False``
para forzar una nueva inicialización en el próximo refresco.
.. note::
Este método puede tardar varios segundos dependiendo de la
cantidad de píxeles que cambien.
"""
self.inicializar()
print("[PAN] Refrescando...")
self._cmd(0x04); self._esperar(3000, "power-on") # PON
# OLD DATA = blanco (para forzar transición limpia)
self._cmd(0x10)
self.dc.value(1); self.cs.value(0)
self.spi.write(b'\xFF' * self.buffer_size)
self.cs.value(1)
# NEW DATA
self._cmd(0x13)
self.dc.value(1); self.cs.value(0)
self.spi.write(self.buffer)
self.cs.value(1)
# REFRESH
self._cmd(0x12)
self._esperar(6000, "refresh")
self._cmd(0x02) # POF
self._esperar(2000, "power-off")
print("[PAN] Refresco completo")
self.inicializado = False
[documentos]
def dormir(self):
"""
Pone el panel en modo *deep sleep* (comando ``DSLP`` 0x07 con
el *magic byte* ``0xA5``) y corta la alimentación externa
(``VEXT = 0``).
Cualquier excepción durante el proceso se silencia, de modo
que este método puede invocarse de forma segura incluso si el
panel nunca llegó a inicializarse.
"""
try:
self._cmd(0x07); self._data(0xA5)
self.vext.value(0)
except Exception:
pass