En este tutorial vamos a construir un reloj analógico usando una pantalla e-Paper de 4,2″ y un ESP32. El reloj sincronizará la hora con un proveedor de tiempo en internet (servidor SNTP). Además, el ESP32 y la pantalla entrarán en modo de suspensión entre actualizaciones para aumentar el tiempo de funcionamiento del reloj con batería.
Empecemos con las piezas necesarias.
Piezas necesarias
Estoy usando el ES32 lite como microprocesador, ya que es barato y tiene una interfaz de carga de batería, lo que te permite hacer funcionar el reloj con una batería LiPo. Cualquier otro ESP32 o ESP8266 con suficiente memoria también funcionará, pero es preferible que tenga una interfaz de carga de batería.

Pantalla e-Paper de 4,2″

ESP32 lite

Cable de datos USB

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 e-paper
La pantalla e-Paper utilizada en este proyecto es un módulo de 4,2″, con una 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 puente/switch en la parte trasera para cambiar de SPI de 4 hilos a SPI de 3 hilos. Aquí vamos a usar el SPI de 4 hilos por defecto, así que no deberías tener que cambiar nada.

El módulo de pantalla funciona con 3,3V o 5V, tiene un consumo en reposo muy bajo de 0,01µA y consume solo unos 26,4mW mientras se actualiza. Para más información sobre pantallas e-Paper 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 e-Paper
Primero, vamos a conectar y probar el funcionamiento de la e-Paper. La siguiente imagen muestra el cableado completo para alimentación y SPI.

A continuación tienes 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 e-Paper | 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 en mi caso conecté la pantalla directamente al ESP32 con cables Dupont y también añadí una batería LiPo para probar el funcionamiento del reloj con batería. La foto de abajo muestra cómo quedó mi montaje.

Instalar la librería GxEPD2
Antes de poder dibujar o escribir en la pantalla e-Paper necesitamos instalar dos librerías. La Adafruit_GFX librería gráfica, que proporciona un conjunto común de primitivas gráficas (texto, puntos, líneas, círculos, etc.). Y la GxEPD2 librería, que proporciona el driver gráfico para la pantalla E-Paper.
Simplemente instala las librerías de la forma habitual. Tras la instalación deberían aparecer en el Library Manager como se muestra a continuación.

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é bien conectada o que se haya elegido el driver de pantalla incorrecto. La línea de código crítica es esta, donde especificamos el driver para la pantalla de 4,2″:
GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
El Readme de la GxEPD2 librería enumera todas las pantallas soportadas y puedes encontrar los detalles en los archivos de cabecera, por ejemplo GxEPD2.h . Busca el driver específico para tu pantalla. Puede que tengas que probar varios.
Código para un reloj analógico en e-Paper
El siguiente es el código completo para un reloj analógico que sincroniza automáticamente la hora con un servidor de tiempo en internet (SNTP) y muestra la hora y la fecha en una pantalla e-Paper de 4,2″. Entre actualizaciones, la pantalla y el ESP32 entran en modo de suspensión para ahorrar batería. Échale un vistazo rápido al código y luego veremos los detalles.
#include "GxEPD2_BW.h"
#include "Fonts/FreeSans9pt7b.h"
#include "Fonts/FreeSansBold9pt7b.h"
#include "WiFi.h"
#include "esp_sntp.h"
const char* TIMEZONE = "AEST-10AEDT,M10.1.0,M4.1.0/3";
const char* SSID = "SSID";
const char* PWD = "PASSWORD";
const char* DAYSTR[] = {
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
};
// W, H flipped due to setRotation(1)
const int H = GxEPD2_420_GDEY042T81::WIDTH;
const int W = GxEPD2_420_GDEY042T81::HEIGHT;
const int CW = W / 2;
const int CH = H / 2;
const int R = min(W, H) / 2;
const uint16_t WHITE = GxEPD_WHITE;
const uint16_t BLACK = GxEPD_BLACK;
RTC_DATA_ATTR uint16_t wakeups = 0;
GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
void initDisplay() {
bool initial = wakeups == 0;
epd.init(115200, initial, 50, false);
epd.setRotation(1);
epd.setTextSize(1);
epd.setTextColor(BLACK);
}
void setTimezone() {
setenv("TZ", TIMEZONE, 1);
tzset();
}
void syncTime() {
if (wakeups % 50 == 0) {
WiFi.begin(SSID, PWD);
while (WiFi.status() != WL_CONNECTED)
;
configTzTime(TIMEZONE, "pool.ntp.org");
}
}
void printAt(int16_t x, int16_t y, const char* text) {
int16_t x1, y1;
uint16_t w, h;
epd.getTextBounds(text, x, y, &x1, &y1, &w, &h);
epd.setCursor(x - w / 2, y + h / 2);
epd.print(text);
}
void printfAt(int16_t x, int16_t y, const char* format, ...) {
static char buff[64];
va_list args;
va_start(args, format);
vsnprintf(buff, 64, format, args);
printAt(x, y, buff);
}
void polar2cart(float x, float y, float r, float alpha, int& cx, int& cy) {
alpha = alpha * TWO_PI / 360;
cx = int(x + r * sin(alpha));
cy = int(y - r * cos(alpha));
}
void drawClockFace() {
int cx, cy;
epd.setFont(&FreeSansBold9pt7b);
epd.drawCircle(CW, CH, R - 2, BLACK);
epd.fillCircle(CW, CH, 8, BLACK);
for (int h = 1; h <= 12; h++) {
float alpha = 360.0 * h / 12;
polar2cart(CW, CH, R - 20, alpha, cx, cy);
printfAt(cx, cy, "%d", h);
polar2cart(CW, CH, R - 40, alpha, cx, cy);
epd.fillCircle(cx, cy, 3, BLACK);
}
for (int m = 1; m <= 12 * 5; m++) {
float alpha = 360.0 * m / (12 * 5);
polar2cart(CW, CH, R - 40, alpha, cx, cy);
epd.fillCircle(cx, cy, 2, BLACK);
}
}
void drawTriangle(float alpha, int width, int len) {
int x0, y0, x1, y1, x2, y2;
polar2cart(CW, CH, len, alpha, x2, y2);
polar2cart(CW, CH, width, alpha - 90, x1, y1);
polar2cart(CW, CH, width, alpha + 90, x0, y0);
epd.drawTriangle(x0, y0, x1, y1, x2, y2, BLACK);
}
void drawClockHands() {
struct tm t;
getLocalTime(&t);
float alphaM = 360 * (t.tm_min / 60.0);
float alphaH = 30 * (t.tm_hour % 12);
drawTriangle(alphaM, 8, R - 50);
drawTriangle(alphaH, 8, R - 65);
}
void drawDateDay() {
struct tm t;
getLocalTime(&t);
epd.setFont(&FreeSans9pt7b);
printfAt(CW, CH+R/3, "%02d-%02d-%02d",
t.tm_mday, t.tm_mon + 1, t.tm_year -100);
printfAt(CW, CH-R/3, "%s", DAYSTR[t.tm_wday]);
}
void drawClock(const void* pv) {
if (wakeups % 120 == 0) {
epd.setFullWindow();
} else {
epd.setPartialWindow(0, 0, W, H);
}
epd.fillScreen(WHITE);
drawClockFace();
drawClockHands();
drawDateDay();
}
void setup() {
initDisplay();
setTimezone();
syncTime();
epd.drawPaged(drawClock, 0);
epd.hibernate();
wakeups = (wakeups + 1) % 1000;
esp_sleep_enable_timer_wakeup(30 * 1000 * 1000);
esp_deep_sleep_start();
}
void loop() {
}
Si subes el código a tu ESP32, deberías ver el siguiente reloj en pantalla:

Muestra la esfera del reloj, las dos manecillas y el día y la fecha actuales.
Librerías
Empezamos incluyendo el GxEPD2_BW.h archivo de cabecera para la pantalla e-Paper en blanco y negro (BW). Si tienes una pantalla de 3 colores, incluye GxEPD2_3C.h , o GxEPD2_4C.h para una pantalla de 4 colores, y GxEPD2_7C.h para una pantalla de 7 colores.
#include "GxEPD2_BW.h" #include "Fonts/FreeSans9pt7b.h" #include "Fonts/FreeSansBold9pt7b.h"
También incluimos dos archivos para las fuentes usadas en las etiquetas del reloj y la fecha. Las etiquetas del reloj usan una fuente en negrita ( FreeSansBold9pt7b ) y la fecha una fuente más fina ( FreeSans9pt7b ), como puedes ver en la imagen del reloj más arriba. Puedes ver un resumen de las AdaFruit GFX fonts aquí.
Por último, incluimos las librerías WiFi.h y esp_snt.h , que necesitaremos para sincronizar el reloj con un servidor SNTP a través de Wi-Fi.
#include "WiFi.h" #include "esp_sntp.h"
Constantes
A continuación definimos algunas constantes. Lo más importante es que tendrás que definir el SSID y PASSWORD de tu WiFi, y la TIMEZONE en la que vives.
const char* SSID = "SSID"; const char* PWD = "PASSWORD"; const char* TIMEZONE = "AEST-10AEDT,M10.1.0,M4.1.0/3";
La especificación de zona horaria anterior » AEST-10AEDT,M10.1.0,M4.1.0/3 » es para Australia, que corresponde a la hora estándar del este de Australia (AEST) con ajustes de horario de verano.
Las partes de esta definición de zona horaria son las siguientes
- AEST: Hora estándar del este de Australia
- -10: Desfase UTC de 10 horas por delante del Tiempo Universal Coordinado (UTC)
- AEDT: Hora de verano del este de Australia
- M10.1.0: El cambio a horario de verano ocurre el primer domingo de octubre
- M4.1.0/3: El regreso a la hora estándar ocurre el primer domingo de abril, con una diferencia de 3 horas respecto a UTC.
Para otras definiciones de zona horaria, consulta la Posix Timezones Database . Simplemente copia y pega la cadena que encuentres allí y cambia la constante TIMEZONE en consecuencia.
Además de la fecha, también queremos mostrar el nombre del día actual, por ejemplo, jueves. La constante DAYSTR define los nombres de los días. Siéntete libre de cambiar el idioma o usar nombres más cortos, pero asegúrate de mantener el orden.
const char* DAYSTR[] = {
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
};
A continuación, redefinimos constantes para el ancho W y la altura H de la pantalla. Esto es solo por comodidad. Ten en cuenta que el ancho y la altura están intercambiados, ya que rotamos la pantalla ( setRotation(1) ) en la función initDisplay .
const int H = GxEPD2_420_GDEY042T81::WIDTH; const int W = GxEPD2_420_GDEY042T81::HEIGHT;
Por último, definimos constantes para la posición central ( CW , CH ) en la pantalla, el radio máximo R de un círculo en la pantalla, y nombres cortos para los colores blanco y negro.
const int CW = W / 2; const int CH = H / 2; const int R = min(W, H) / 2; const uint16_t WHITE = GxEPD_WHITE; const uint16_t BLACK = GxEPD_BLACK;
Variables y objetos
Después de las constantes, definimos una variable global para contar los despertares y el objeto de la pantalla.
RTC_DATA_ATTR uint16_t wakeups = 0; GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
La variable wakeups se incrementa cada vez que el ESP32 sale del modo deep-sleep. Se almacena en RTC memory para que conserve su valor incluso cuando el ESP32 entra en deep-sleep. La usamos para decidir cuándo refrescar la pantalla completamente o parcialmente, y con qué frecuencia sincronizar la hora.
El objeto epd representa la pantalla ( e – p aper d isplay). Esta definición de objeto debe coincidir con el tipo de pantalla e-Paper que tienes. Aquí usamos una de 4,2 pulgadas (= _420). Consulta el Readme de la GxEPD2 librería y el archivo GxEPD2.h para ver las pantallas soportadas.
Función initDisplay
La función initDisplay inicializa la pantalla, establece la orientación en horizontal, el tamaño de texto en 1 y el color de texto en negro.
void initDisplay() {
bool initial = wakeups == 0;
epd.init(115200, initial, 50, false);
epd.setRotation(1);
epd.setTextSize(1);
epd.setTextColor(BLACK);
}
epd.init() normalmente provoca un refresco completo de la pantalla al ejecutarse. Sin embargo, queremos un refresco completo solo tras reiniciar el ESP32 y un refresco parcial al salir de deep sleep. Para lograr esto, establecemos el parámetro initial en la función epd.init() a true cuando wakeups == 0 , es decir, tras un reinicio, y en caso contrario initial es false, para un refresco parcial.
Función setTimezone
La función setTimezone establece la TIMEZONE . Hay que llamarla siempre en la función setup, ya que tras un deep-sleep o un reinicio se pierde la información de la zona horaria.
void setTimezone() {
setenv("TZ", TIMEZONE, 1);
tzset();
}
Función syncTime
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() .
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.
void syncTime() {
if (wakeups % 50 == 0) {
WiFi.begin(SSID, PWD);
while (WiFi.status() != WL_CONNECTED)
;
configTzTime(TIMEZONE, "pool.ntp.org");
}
}
Sin embargo, solo queremos sincronizar la hora de vez en cuando. Como el ESP32 está configurado para dormir 30 segundos (ver función loop ), la expresión wakeups % 50 == 0 , asegura que la hora solo se sincroniza cada 25 minutos (30seg * 50 / 60seg).
Puedes cambiar esto, pero ten en cuenta que sincronizaciones más frecuentes consumirán más batería (el Wi-Fi consume bastante). Si sincronizas menos a menudo, el reloj tardará más en ajustarse al horario de verano.
Funciones printAt
Las funciones printAt muestran texto centrado en las coordenadas de pantalla especificadas.
void printAt(int16_t x, int16_t y, const char* text) {
int16_t x1, y1;
uint16_t w, h;
epd.getTextBounds(text, x, y, &x1, &y1, &w, &h);
epd.setCursor(x - w / 2, y + h / 2);
epd.print(text);
}
void printfAt(int16_t x, int16_t y, const char* format, ...) {
static char buff[64];
va_list args;
va_start(args, format);
vsnprintf(buff, 64, format, args);
printAt(x, y, buff);
}
Mientras que printAt() solo imprime texto plano, la función printfAt() funciona como printf y te permite usar un especificador de formato, por ejemplo printfAt(x, y, "%02d-%02d-%02d", m, h, y);
Función polar2cart
La función polar2cart convierte polar coordinates dados por un radio r y un ángulo alpha en cartesian coordinates cx, cy .
void polar2cart(float x, float y, float r, float alpha, int& cx, int& cy) {
alpha = alpha * TWO_PI / 360;
cx = int(x + r * sin(alpha));
cy = int(y - r * cos(alpha));
}
Esta función es muy útil, ya que los datos de la hora como las 8:15 son esencialmente coordenadas polares en el reloj y hay que convertirlas al sistema de coordenadas cartesianas que usa la pantalla.
Función drawClockFace
La función drawClockFace() , como su nombre indica, dibuja la esfera del reloj. Esto incluye el marco, las marcas de minutos y las etiquetas y marcas de las horas. Como puedes ver, hace buen uso de la función polar2cart() que comentamos antes.
void drawClockFace() {
int cx, cy;
epd.setFont(&FreeSansBold9pt7b);
// Frame
epd.drawCircle(CW, CH, R - 2, BLACK);
epd.fillCircle(CW, CH, 8, BLACK);
// Hour ticks and labels
for (int h = 1; h <= 12; h++) {
float alpha = 360.0 * h / 12;
polar2cart(CW, CH, R - 20, alpha, cx, cy);
printfAt(cx, cy, "%d", h);
polar2cart(CW, CH, R - 40, alpha, cx, cy);
epd.fillCircle(cx, cy, 3, BLACK);
}
// Minute ticks
for (int m = 1; m <= 12 * 5; m++) {
float alpha = 360.0 * m / (12 * 5);
polar2cart(CW, CH, R - 40, alpha, cx, cy);
epd.fillCircle(cx, cy, 2, BLACK);
}
}
Función drawTriangle
La función drawTriangle() dibuja un triángulo pero en coordenadas polares. La base del triángulo está en el centro del reloj y el triángulo está orientado con el ángulo alpha . width es el ancho de la base y len es la longitud del triángulo. Esta función se usa para dibujar las manecillas del reloj.
void drawTriangle(float alpha, int width, int len) {
int x0, y0, x1, y1, x2, y2;
polar2cart(CW, CH, len, alpha, x2, y2);
polar2cart(CW, CH, width, alpha - 90, x1, y1);
polar2cart(CW, CH, width, alpha + 90, x0, y0);
epd.drawTriangle(x0, y0, x1, y1, x2, y2, BLACK);
}
Puedes cambiar de epd.drawTriangle() a epd.fillTriangle() , si prefieres manecillas rellenas. Son más fáciles de ver pero pueden tapar el día y la fecha.
Función drawClockHands
El dibujo real de las manecillas lo realiza la función drawClockHands() . Obtiene la hora local actual, convierte minutos y horas en ángulos y luego usa drawTriangle() para dibujar las manecillas.
void drawClockHands() {
struct tm t;
getLocalTime(&t);
float alphaM = 360 * (t.tm_min / 60.0);
float alphaH = 30 * (t.tm_hour % 12);
drawTriangle(alphaM, 8, R - 50);
drawTriangle(alphaH, 8, R - 65);
}
Función drawDateDay
Además de la hora mostrada por las manecillas, también quería mostrar la fecha y el día de la semana actuales. La función drawDateDay() obtiene la hora y fecha local y luego imprime el nombre del día, por ejemplo, jueves, en la parte superior de la esfera y la fecha, por ejemplo, 05-09-24, en la parte inferior. Puedes cambiar fácilmente el formato de la fecha para adaptarlo a tu país.
void drawDateDay() {
struct tm t;
getLocalTime(&t);
epd.setFont(&FreeSans9pt7b);
printfAt(CW, CH+R/3, "%02d-%02d-%02d",
t.tm_mday, t.tm_mon + 1, t.tm_year -100);
printfAt(CW, CH-R/3, "%s", DAYSTR[t.tm_wday]);
}
Función drawClock
La función drawClock() lo reúne todo. Limpia la pantalla y luego dibuja la esfera, las manecillas y la fecha.
void drawClock(const void* pv) {
if (wakeups % 120 == 0) {
epd.setFullWindow();
} else {
epd.setPartialWindow(0, 0, W, H);
}
epd.fillScreen(WHITE);
drawClockFace();
drawClockHands();
drawDateDay();
}
Dependiendo del contador wakeups se realiza un refresco completo o parcial de la pantalla. Un refresco completo es lento (2-4 segundos) y provoca bastante parpadeo. Un refresco parcial es mucho más rápido (<0,5 seg) y sin parpadeo.
Sin embargo, un refresco parcial deja una imagen fantasma tenue del contenido anterior. Si te fijas bien en la imagen de abajo puedes ver la imagen fantasma de la manecilla de los minutos, que se movió de las 5 a las 10.

Para eliminar las imágenes fantasma, de vez en cuando queremos hacer un refresco completo de la pantalla. En la función drawClock() lo hacemos cuando el contador wakeup llega a 120: wakeups % 120 == 0 . Como el tiempo de deep-sleep está en 30 segundos, esto significa un refresco completo cada 30seg*120/60seg = 60 minutos.
Funciones setup y loop
Por último, tenemos las funciones habituales setup y loop . La función loop está vacía, ya que el ESP32 entra en deep-sleep al final de la función setup y por tanto la función loop nunca se ejecuta.
void setup() {
initDisplay();
setTimezone();
syncTime();
epd.drawPaged(drawClock, 0);
epd.hibernate();
wakeups = (wakeups + 1) % 1000;
esp_sleep_enable_timer_wakeup(30 * 1000 * 1000);
esp_deep_sleep_start();
}
void loop() {
}
La función setup comienza inicializando la pantalla, estableciendo la zona horaria y (quizá) sincronizando la hora (según el contador de despertares).
Luego llamamos a drawPaged para ejecutar las funciones drawClock y después hibernamos la pantalla para ahorrar energía. Si necesitas más detalles, consulta el tutorial Partial Refresh of e-Paper Display , donde explicamos la función drawPaged con más detalle.
Antes de poner el ESP32 en deep-sleep durante 30 segundos (30 * 1000 * 1000 microsegundos), incrementamos el contador wakeup y calculamos el módulo 1000 para evitar un desbordamiento.
¡Y eso es todo! Con este código tienes un bonito reloj analógico que siempre está en hora y puede funcionar con batería.
Conclusiones
En este tutorial has aprendido cómo construir un reloj analógico en una pantalla e-Paper de 4,2″ que sincroniza la hora con un servidor SNTP y siempre muestra la hora y fecha correctas. El reloj utiliza la capacidad de deep-sleep del ESP32 para reducir el consumo y aumentar el tiempo de funcionamiento con batería.
Si usas el ESP32 LOLIN Lite sugerido, conectar una batería LiPo recargable es sencillo y podrías usar un MAX1704X para monitorizar el nivel de carga.
Por último, si prefieres un reloj digital en vez de uno analógico, échale un vistazo a nuestro tutorial Digital Clock on e-Paper Display .
¡Feliz cacharreo ; )

