En este tutorial aprenderás a implementar un Calendario Mensual en una pantalla de papel electrónico usando un ESP32. La pantalla también mostrará la hora actual y sincronizará la hora con un proveedor de tiempo por internet (servidor SNTP), para que la hora y el calendario siempre sean precisos. No más ajustes manuales de hora y fecha.
Piezas necesarias
Estoy usando el ESP32 lite como microprocesador, ya que es barato y tiene una interfaz de carga de batería, lo que permite que el calendario funcione con una batería LiPo. Cualquier otro ESP32 o ESP8266 con suficiente memoria también funcionará, pero preferiblemente consigue uno con interfaz de carga de batería. Deberías poder usar un Arduino también, pero no lo he probado.

Pantalla de papel electrónico de 4.2″

ESP32 lite

Cable USB de datos

Juego de cables Dupont

Protoboard
Makerguides is a participant in affiliate advertising programs designed to provide a means for sites to earn advertising fees by linking to Amazon, AliExpress, Elecrow, and other sites. As an Affiliate we may earn from qualifying purchases.
Pantalla de papel electrónico
La pantalla de papel electrónico usada en este proyecto es un módulo de 4.2″, con resolución de 400×300 píxeles, 4 niveles de gris, un tiempo de refresco parcial de 0.4 segundos y un controlador integrado con interfaz SPI.

Ten en cuenta que el módulo tiene un pequeño jumper o interruptor en la parte trasera para cambiar de SPI de 4 hilos a SPI de 3 hilos. Aquí usaremos el SPI de 4 hilos por defecto, así que no deberías cambiar nada.

El módulo de pantalla funciona a 3.3V o 5V, tiene un consumo en modo suspensión muy bajo de 0.01µA y consume solo unos 26.4mW durante el refresco. Para más información sobre pantallas de papel electrónico en general, echa un vistazo a los siguientes dos tutoriales: Interfacing Arduino To An E-ink Display y Weather Station on e-Paper Display.
Conexión y prueba de la pantalla de papel electrónico
Primero, conectemos y probemos la función de la pantalla de papel electrónico. La siguiente imagen muestra el cableado completo para alimentación y SPI.

A continuación una tabla con todas las conexiones para mayor comodidad. Ten en cuenta que puedes alimentar la pantalla con 3.3V o 5V, pero el ESP32-lite solo tiene salida de 3.3V y las líneas de datos SPI deben ser de 3.3V.
| Pantalla de papel electrónico | ESP32 lite |
|---|---|
| CS/SS | 5 |
| SCL/SCK | 18 |
| SDA/DIN/MOSI | 23 |
| BUSY | 15 |
| RES/RST | 2 |
| DC | 0 |
| VCC | 3.3V |
| GND | G |
Puedes usar una protoboard para conectar todo. Pero yo conecté la pantalla directamente al ESP32 con cables Dupont. La imagen abajo muestra cómo quedó mi montaje.

Instalar la librería GxEPD2
Antes de poder dibujar o escribir en la pantalla de papel electrónico necesitamos instalar dos librerías. La Adafruit_GFX librería gráfica, que provee un conjunto común de primitivas gráficas (texto, puntos, líneas, círculos, etc.). Y la GxEPD2 librería que proporciona el software controlador gráfico para la pantalla de papel electrónico.
Solo instala las librerías de la manera habitual. Después de la instalación deberían aparecer en el Library Manager así:

Código de prueba
Una vez instalada la librería, ejecuta el siguiente código de prueba para asegurarte de que todo funciona.
#include "GxEPD2_BW.h"
//CS(SS)=5, SCL(SCK)=18, SDA(MOSI)=23, BUSY=15, RES(RST)=2, DC=0
GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
void setup() {
epd.init(115200, true, 50, false);
epd.setRotation(1);
epd.setTextColor(GxEPD_BLACK);
epd.setTextSize(2);
epd.setFullWindow();
epd.fillScreen(GxEPD_WHITE);
epd.setCursor(90, 190);
epd.print("Makerguides");
epd.display();
epd.hibernate();
}
void loop() {}
El código imprime el texto «Makerguides» y tras un parpadeo de la pantalla (refresco completo) deberías verlo en tu pantalla como se muestra abajo:

Si no es así, algo está mal. Lo más probable es que la pantalla no esté correctamente cableada o que se haya elegido un controlador de pantalla incorrecto. La línea crítica de código es esta, donde especificamos el controlador para la pantalla de 4.2″:
GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
La Readme GxEPD2 librería lista todas las pantallas soportadas y puedes encontrar los detalles en los archivos header, por ejemplo GxEPD2.h. Encuentra el controlador específico para tu pantalla. Esto puede requerir prueba y error.
Código para un calendario en pantalla de papel electrónico
En esta sección vamos a escribir el código para nuestro calendario. La captura de pantalla abajo muestra cómo se verá:

El día y la hora actuales se muestran en la parte superior. Debajo, la pantalla muestra el mes y año actuales y los días del mes con el día actual marcado.
A continuación está el código para este calendario. Échale un vistazo rápido para tener una visión general y luego entraremos en detalles:
#include <WiFi.h>
#include <time.h>
#include <GxEPD2_BW.h>
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSansBold9pt7b.h>
#include <Fonts/FreeSansBold12pt7b.h>
#include <Fonts/FreeSansBold24pt7b.h>
const char* ssid = "SSID";
const char* password = "PWD";
const char* ntpServer = "pool.ntp.org";
const char* timezone = "AEST-10AEDT,M10.1.0,M4.1.0/3";
const char* dayNames[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
char* dayLongNames[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" };
const char* monthNames[] = {
"January", "February", "March", "April", "May",
"June", "July", "August", "September", "October", "November", "December"
};
const int shifts[] = {
3, 0, 0, 1, 0, 0, 0, 0, 0, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1
};
//CS(SS)=5, SCL(SCK)=18, SDA(MOSI)=23, BUSY=15, RES(RST)=2, DC=0
GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
bool isLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
int daysInMonth(int year, int month) {
int days[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && isLeapYear(year)) {
return 29;
}
return days[month-1];
}
// Return day of the week (0=Sunday, 1=Monday, ..., 6=Saturday)
int dayOfWeek(int year, int month, int day) {
tm timeinfo = {};
timeinfo.tm_year = year - 1900;
timeinfo.tm_mon = month - 1;
timeinfo.tm_mday = day;
mktime(&timeinfo);
return timeinfo.tm_wday;
}
void drawAt(int x, int y, const char* text, int shift, bool mark) {
int16_t xb, yb;
uint16_t wb, hb;
epd.getTextBounds(text, 0, 0, &xb, &yb, &wb, &hb);
epd.setCursor(x - wb - shift, y);
epd.setTextColor(mark ? GxEPD_WHITE : GxEPD_BLACK);
if (mark) {
epd.fillRect(x - wb - shift - 3, y - hb - 3, wb + 8, hb + 8, GxEPD_BLACK);
}
epd.print(text);
}
void drawCalendar(int year, int month, int day) {
char buf[6];
int dy = 25;
int dx = 56;
int xoff = 10;
int yoff = 100;
// Print name of month and the year
epd.setPartialWindow(0, yoff - 20, 400, 300 - (yoff - 20));
epd.fillRect(6, yoff - 20, 400 - 12, 28, GxEPD_BLACK);
epd.setFont(&FreeSansBold12pt7b);
epd.setTextColor(GxEPD_WHITE);
epd.setCursor(xoff, yoff);
epd.printf("%s %d", monthNames[month - 1], year);
// print names of week days
epd.setTextColor(GxEPD_BLACK);
epd.setFont(&FreeSansBold9pt7b);
xoff += 30;
for (int i = 0; i < 7; i++) {
int x = xoff + i * dx;
int y = yoff + 35;
drawAt(x, y, dayNames[i], 0, false);
}
// Print days of the month
int days = daysInMonth(year, month);
int firstDay = dayOfWeek(year, month, 1);
int x = xoff;
int y = yoff + 65;
for (int i = 0; i < firstDay; i++) {
x += dx; // shift pos of first day
}
epd.setFont(&FreeSans9pt7b);
for (int d = 1; d <= days; d++) {
sprintf(buf, "%d", d);
drawAt(x, y, buf, shifts[d - 1], d == day);
x += dx;
if ((firstDay + d) % 7 == 0) {
y += dy;
x = xoff;
}
}
}
void updateTime(const void* pv) {
struct tm t;
getLocalTime(&t);
epd.setTextColor(GxEPD_BLACK);
epd.setFont(&FreeSansBold24pt7b);
epd.setCursor(10, 60);
epd.setPartialWindow(0, 0, 400, 80);
epd.printf("%s %02d:%02d", dayLongNames[t.tm_wday], t.tm_hour, t.tm_min);
}
void updateCalendar(const void* pv) {
struct tm t;
getLocalTime(&t);
drawCalendar(t.tm_year + 1900, t.tm_mon + 1, t.tm_mday);
}
bool isNewDay() {
struct tm t;
getLocalTime(&t);
return t.tm_hour == 0 && t.tm_min == 0;
}
void syncTime() {
WiFi.begin(ssid, password);
for (int i = 0; i < 10 && WiFi.status() != WL_CONNECTED; i++) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED)
configTzTime(timezone, ntpServer);
}
void clearScreen() {
epd.setFullWindow();
epd.fillScreen(GxEPD_WHITE);
epd.display();
}
void initEPD() {
epd.init(115200, true, 50, false);
epd.setRotation(0);
}
void setup() {
Serial.begin(115200);
syncTime();
initEPD();
clearScreen();
epd.drawPaged(updateCalendar, 0);
epd.hibernate();
}
void loop() {
if (isNewDay()) {
syncTime();
clearScreen();
epd.drawPaged(updateCalendar, 0);
}
epd.drawPaged(updateTime, 0);
epd.hibernate();
delay(60 * 1000); // 60 seconds
}
Librerías
El código comienza incluyendo las librerías necesarias para conectividad WiFi, gestión del tiempo y funcionalidad de la pantalla de papel electrónico.
#include <WiFi.h> #include <time.h> #include <GxEPD2_BW.h>
Fuentes
Después incluimos las fuentes usadas. Puedes elegir otras y mientras tengan el mismo tamaño (9pt, 12pt, 24pt) el diseño del calendario debería funcionar. Para la lista de fuentes disponibles mira here.
#include <Fonts/FreeSans9pt7b.h> #include <Fonts/FreeSansBold9pt7b.h> #include <Fonts/FreeSansBold12pt7b.h> #include <Fonts/FreeSansBold24pt7b.h>
Constantes
Luego definimos algunas constantes para las credenciales WiFi, servidor NTP, zona horaria, arrays con nombres de días y meses y correcciones de diseño.
const char* ssid = "SSID";
const char* password = "PWD";
const char* ntpServer = "pool.ntp.org";
const char* timezone = "AEST-10AEDT,M10.1.0,M4.1.0/3";
const char* dayNames[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
char* dayLongNames[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" };
const char* monthNames[] = {
"January", "February", "March", "April", "May",
"June", "July", "August", "September", "October", "November", "December"
};
const int shifts[] = {
3, 0, 0, 1, 0, 0, 0, 0, 0, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1
};
Tendrás que reemplazar las constantes SSID y PASSWORD por las de tu WiFi, y probablemente también la constante TIMEZONE de la zona horaria donde vives.
La especificación de zona horaria «AEST-10AEDT,M10.1.0,M4.1.0/3» es para Australia, que corresponde a la hora estándar del este australiano (AEST) con ajustes de horario de verano. Para otras definiciones de zona horaria, echa un vistazo a Posix Timezones Database. Solo copia y pega la cadena que encuentres allí y cambia la constante TIMEZONE en consecuencia.
Desplazamientos
Las constantes para los nombres de días y meses son evidentes, pero la constante shifts requiere explicación. Los nombres de los días y los días del mes en la vista del calendario se dibujan alineados a la derecha. Uso la función getTextBounds() para lograr esto. Sin embargo, aparentemente los límites de texto calculados por esta función no son del todo precisos y la alineación derecha tampoco. Mira abajo:

La constante shifts desplaza la alineación derecha por el número especificado de píxeles para un día específico, por ejemplo shifts[0] desplaza el día 1 tres píxeles a la derecha. La imagen arriba compara la alineación de las columnas de días sin (izquierda) y con (derecha) la corrección de desplazamiento. Puedes ver que los números de días a la izquierda no están perfectamente alineados a la derecha, lo que resulta molesto al ver el calendario completo. Las constantes de desplazamiento corrigen esto.
Configuración de la pantalla
La siguiente línea de código crea el objeto de pantalla y lo vincula a las conexiones de pines para CS, SCL, SDA, BUSY, RES y DC.
//CS(SS)=5, SCL(SCK)=18, SDA(MOSI)=23, BUSY=15, RES(RST)=2, DC=0 GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
Si usas una pantalla de papel electrónico diferente, puede que tengas que elegir un controlador distinto allí. La librería Readme GxEPD2 lista todas las pantallas soportadas y puedes encontrar los detalles en los archivos header, por ejemplo GxEPD2.h y GxEPD2_display_selection_new_style.h.
Funciones de fecha
A continuación tenemos algunas funciones de fecha. La función isLeapYear() devuelve true si el year dado es un año bisiesto.
bool isLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
La función daysInMonth() devuelve el número de días en un mes dado, teniendo en cuenta los años bisiestos en febrero.
int daysInMonth(int year, int month) {
int days[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && isLeapYear(year)) {
return 29;
}
return days[month - 1];
}
Y la función dayOfWeek() calcula el índice del día de la semana para una fecha dada. Devuelve valores de 0 (domingo) a 6 (sábado).
int dayOfWeek(int year, int month, int day) {
tm timeinfo = {};
timeinfo.tm_year = year - 1900;
timeinfo.tm_mon = month - 1;
timeinfo.tm_mday = day;
mktime(&timeinfo);
return timeinfo.tm_wday;
}
Funciones de dibujo
Función drawAt
La función drawAt() es responsable de dibujar texto en la pantalla de papel electrónico en coordenadas especificadas, con marcado opcional.
void drawAt(int x, int y, const char* text, int shift, bool mark) {
int16_t xb, yb;
uint16_t wb, hb;
epd.getTextBounds(text, 0, 0, &xb, &yb, &wb, &hb);
epd.setCursor(x - wb - shift, y);
epd.setTextColor(mark ? GxEPD_WHITE : GxEPD_BLACK);
if (mark) {
epd.fillRect(x - wb - shift - 3, y - hb - 3, wb + 8, hb + 8, GxEPD_BLACK);
}
epd.print(text);
}
El marcado se usa para resaltar el día actual subrayándolo con un fondo negro y escribiendo el texto en blanco. Abajo un ejemplo de cómo se ve para el día 6:

Fíjate en el parámetro shift, que se usa para corregir la alineación derecha de los días como se describió arriba. Obtenemos los límites del texto llamando a getTextBounds(), luego desplazamos el texto a la derecha restando el ancho wb del texto de la posición de dibujo x y también restando la corrección shift.
Función drawCalendar
La función drawCalendar() dibuja el calendario completo para un día, mes y año especificados.
void drawCalendar(int year, int month, int day) {
...
// Print name of month and the year
epd.setPartialWindow(0, yoff - 20, 400, 300 - (yoff - 20));
epd.fillRect(6, yoff - 20, 400 - 12, 28, GxEPD_BLACK);
epd.setFont(&FreeSansBold12pt7b);
epd.setTextColor(GxEPD_WHITE);
epd.setCursor(xoff, yoff);
epd.printf("%s %d", monthNames[month - 1], year);
// Print names of week days
epd.setTextColor(GxEPD_BLACK);
epd.setFont(&FreeSansBold9pt7b);
xoff += 30;
for (int i = 0; i < 7; i++) {
int x = xoff + i * dx;
int y = yoff + 35;
drawAt(x, y, dayNames[i], 0, false);
}
// Print days of the month
int days = daysInMonth(year, month);
int firstDay = dayOfWeek(year, month, 1);
int x = xoff;
int y = yoff + 65;
for (int i = 0; i < firstDay; i++) {
x += dx; // shift pos of first day
}
epd.setFont(&FreeSans9pt7b);
for (int d = 1; d <= days; d++) {
sprintf(buf, "%d", d);
drawAt(x, y, buf, shifts[d - 1], d == day);
x += dx;
if ((firstDay + d) % 7 == 0) {
y += dy;
x = xoff;
}
}
}
La vista del calendario está esencialmente dividida en cuatro partes. La primera parte (1) en la parte superior muestra el nombre del día actual y la hora actual. La segunda parte (2) muestra el mes y año actuales. La parte (3) muestra los nombres de los días. Y la parte (4) muestra los días del mes.

Función updateTime
La función drawCalendar() mostrada arriba dibuja las partes 2 a 4, mientras que la función updateTime() abajo dibuja la parte 1. Obtiene la hora local actual y la muestra.
void updateTime(const void* pv) {
struct tm t;
getLocalTime(&t);
epd.setTextColor(GxEPD_BLACK);
epd.setFont(&FreeSansBold24pt7b);
epd.setCursor(10, 60);
epd.setPartialWindow(0, 0, 400, 100);
epd.printf("%s %02d:%02d", dayLongNames[t.tm_wday], t.tm_hour, t.tm_min);
}
Función updateCalendar
De forma similar, la función updateCalendar() obtiene la fecha actual y luego llama a drawCalendar() para actualizar el calendario mostrado.
void updateCalendar(const void* pv) {
struct tm t;
getLocalTime(&t);
drawCalendar(t.tm_year + 1900, t.tm_mon + 1, t.tm_mday);
}
Ten en cuenta que ambas funciones de actualización, updateTime() y updateCalendar() realizan un refresco parcial, que es más rápido y evita el parpadeo de la pantalla. Si quieres aprender más sobre la operación de refresco parcial, echa un vistazo al tutorial Partial Refresh of e-Paper Display.
Sincronización de tiempo
La función syncTime() crea una conexión WiFi y luego sincroniza el reloj interno del ESP32 con el servidor SNTP «pool.ntp.org» llamando a configTzTime().
void syncTime() {
WiFi.begin(ssid, password);
for (int i = 0; i < 10 && WiFi.status() != WL_CONNECTED; i++) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED)
configTzTime(timezone, ntpServer);
}
Puedes especificar otros servidores SNTP, o incluso varios, para conectarte. Consulta el tutorial How to synchronize ESP32 clock with SNTP server para más información.
Ten en cuenta que la función syncTime() intenta conectarse al WiFi solo 10 veces y luego se rinde. Esto tiene la ventaja de que el calendario sigue funcionando, incluso si no hay WiFi disponible. De lo contrario, el código se bloquearía aquí hasta que el WiFi esté disponible.
Función setup
En la función setup() inicializamos la comunicación serial, sincronizamos la hora, inicializamos la pantalla de papel electrónico, limpiamos la pantalla y dibujamos el calendario.
void setup() {
Serial.begin(115200);
syncTime();
initEPD();
clearScreen();
epd.drawPaged(updateCalendar, 0);
epd.hibernate();
}
Esto significa que cada vez que se reinicia el ESP32, la hora se sincroniza y no tienes que preocuparte por ajustar la hora y fecha manualmente.
Función loop
La función loop() comprueba si ha comenzado un nuevo día, sincroniza la hora si es así, limpia la pantalla y actualiza el calendario y la hora mostrados.
void loop() {
if (isNewDay()) {
syncTime();
clearScreen();
epd.drawPaged(updateCalendar, 0);
}
epd.drawPaged(updateTime, 0);
epd.hibernate();
delay(60 * 1000); // 60 seconds
}
El calendario se redibuja/actualiza solo una vez cada nuevo día, mientras que la hora se actualiza cada 60 segundos. Todas estas actualizaciones son refrescos parciales, que son rápidos y sin parpadeo.
Sin embargo, la función clearScreen() que se llama una vez al día, cuando se actualiza el calendario, realiza un refresco completo. Esto es para evitar el «quemado» de imágenes residuales del refresco parcial. El tutorial Waveshare Manual tiene más información sobre esto.
Ten en cuenta que la sincronización diaria de la hora se encarga de los cambios de horario de verano, pero habrá unas pocas horas en las que el reloj estará desfasado. Puedes evitar esto sincronizando cada 30 minutos aproximadamente. Consulta el tutorial Digital Clock on e-Paper Display para código de ejemplo.
Conclusiones
En este tutorial aprendiste a implementar un Calendario Mensual en una pantalla de papel electrónico usando un ESP32.
Las pantallas de papel electrónico consumen muy poca energía y por eso son ideales para proyectos alimentados por batería. Podrías hacer funcionar el ESP32 con el calendario usando una batería LiPo, pero en ese caso sería mejor poner el ESP32 en modo deep-sleep entre actualizaciones de pantalla. No te mostré cómo hacerlo en este tutorial, pero el tutorial Digital Clock on e-Paper Display tiene código de ejemplo para esto.
Además, para un calendario alimentado por batería sería útil monitorizar el nivel de carga de la batería LiPo y mostrar un pequeño símbolo de batería. Echa un vistazo al tutorial Monitor Battery Levels with a MAX1704X si quieres aprender más sobre monitorización de baterías.
Además de la carga de la batería, también podrías mostrar la temperatura ambiente o datos meteorológicos. Consulta el tutorial Weather Station on e-Paper Display para más información sobre eso.
Si tienes algún comentario, no dudes en dejarlo en la sección de comentarios.
¡Feliz bricolaje ; )

