Código fuente para pantalla

# 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