¿Cuántos días faltan para el próximo feriado? La respuesta cambia todos los días y buscarla en el celular corta el foco. La solución: un panel colgado en la pared que muestra el número grande en una pantalla e-Paper y solo despierta una vez por día a medianoche para actualizarse. El resto del tiempo consume prácticamente cero.
Este tutorial arma ese panel desde cero con la placa EE04 de Seeed Studio, un XIAO ESP32-S3 y una pantalla e-Paper de 5,83". Al final vas a tener un dispositivo autónomo que sincroniza su reloj por NTP, consulta la API pública de feriados de Nager.Date por país, calcula la diferencia en días y la dibuja. Y como bonus, vas a entender bien cómo hacer HTTPS + JSON desde ESP32. patrón que reutilizás para cualquier proyecto IoT.
Concepto: el flujo del panel
Antes del código, el flujo mental del sistema:
- El ESP32 arranca (desde reset o desde deep sleep). Se conecta al WiFi.
- Sincroniza su RTC interno contra un servidor NTP público.
- Hace un GET HTTPS a la API de Nager.Date pidiendo el listado de feriados del año, para el código de país configurado.
- Parsea el JSON de respuesta, recorre feriados en orden, calcula la diferencia en días respecto a hoy.
- El primer feriado con diferencia ≥ 0 es el próximo (o el de hoy). Dibuja el número de días y el nombre del feriado en la pantalla.
- Calcula cuántos segundos faltan hasta la próxima medianoche y entra en deep sleep hasta ese momento.
- Al día siguiente, wake up automático y vuelve a empezar.
Todo el "trabajo" se hace en menos de 15 segundos por día. El resto: e-Paper mostrando la imagen sin corriente + ESP32 durmiendo.
Hardware
- XIAO ESP32-S3 (montado sobre la EE04. placa driver de Seeed Studio para e-Paper)
- Pantalla e-Paper 5,83" monocromo de Seeed Studio (controlador UC8179, 648×480 píxeles)
- Cable USB-C para programación
- Fuente 5V o batería LiPo con circuito de carga (opcional; el consumo es tan bajo que una LiPo dura meses)

Antes de compilar: los dos archivos generados
Como cualquier proyecto con Seeed_GFX, necesitas generar dos headers con las herramientas online de Seeed:
1. driver.h. combo pantalla + placa. Para la 5,83" con la EE04:
#define BOARD_SCREEN_COMBO 503 // 5.83 inch monochrome ePaper Screen (UC8179)
#define USE_XIAO_EPAPER_DISPLAY_BOARD_EE04
2. bitmap.h. imagen de fondo del panel. El tutorial original usa una imagen generada con IA. La convertís a bitmap monocromo con el dithering tool de SenseCraft (algoritmo Floyd Steinberg, 1 bpp, tamaño 648×480 para hacer match exacto con el panel).
El código completo
/*
* Autor: Ernesto Tolocka (Profe Tolocka)
* Date: 2026-2-15
* Descripción: Muestra el próximo dia feriado en pantalla
* Notas: Usa la placa EE04 y una EPD de 5.83-inch Monocromo
*
* Licencia: MIT
*/
#include <WiFi.h>
#include <time.h>
#include "TFT_eSPI.h"
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h> // Para parsear JSON
#include <esp_sleep.h> //Para deep sleep
#include "bitmap.h" //Imagen de fondo
EPaper epaper;
//Nombre de la red WiFi y contraseña
const char* ssid = "yourSSID";
const char* password = "yourPassword";
//Localizacion
//Modifica estos valores de acuerdo a tu país
const int timeZone = -3; //Timezone local
const char* country = "AR"; //Código de país. Mira https://date.nager.at/Country
//Endpoint de Date.Nager
String endpoint = String("https://date.nager.at/api/v3/PublicHolidays/2026/") + country;
//Función para pasar de fecha en formato texto a epoch
time_t stringDateToEpoch (const char* stringDate) {
//Pasa un string como 2026-02-02 a epoch
int year,month, day;
struct tm timeDate = {}; //Arranca vacío
sscanf(stringDate, "%d-%d-%d", &year, &month, &day);
timeDate.tm_sec=0;
timeDate.tm_min=0;
timeDate.tm_hour=0;
timeDate.tm_mday=day;
timeDate.tm_mon=month-1;
timeDate.tm_year=year-1900;
Serial.println(&timeDate, "Time: %H:%M:%S");
Serial.println(&timeDate, "Date: %Y-%m-%d");
//Pasar la estructura a epoch
return (mktime(&timeDate));
}
// Funcion para limpiar acentos o tildes
String quitarAcentos(String s) {
s.replace("á", "a");
s.replace("é", "e");
s.replace("í", "i");
s.replace("ó", "o");
s.replace("ú", "u");
s.replace("ñ", "n");
s.replace("Á", "A");
s.replace("É", "E");
s.replace("Í", "I");
s.replace("Ó", "O");
s.replace("Ú", "U");
s.replace("Ñ", "N");
return s;
}
// Función para llevar el ESP32 a deep sleep
void sleepSeconds (uint32_t s) {
if (s < 60) s = 60; //Por las dudas
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
esp_sleep_enable_timer_wakeup((uint64_t)s * 1000000ULL);
esp_deep_sleep_start();
}
// Función para conectar al Wifi con timeout
bool connectWiFi(uint32_t timeoutMs) {
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && (millis() - t0) < timeoutMs) {
delay(250);
Serial.print(".");
}
Serial.println();
return WiFi.status() == WL_CONNECTED;
}
void setup() {
Serial.begin(115200);
if (!connectWiFi(30000)) {
Serial.println("WiFi no conecta. A dormir 5 min.");
sleepSeconds(300);
return;
}
Serial.println("WiFi connected!");
//Configurar e inicializar pantalla
epaper.begin();
epaper.fillScreen(TFT_WHITE); // Borrar pantalla
epaper.setFreeFont(&Yellowtail_32); // Seleccionar tipo de letra
epaper.setRotation(0);
epaper.setTextDatum(MC_DATUM); // Configurar centrado
epaper.setTextSize (1);
epaper.drawString ("Dias hasta el Proximo Feriado:", epaper.width()/2,98);
// Cargar imagen de fondo
epaper.drawBitmap(0, 0, holidayBack, 648, 480, TFT_BLACK);
// Mostrar todo
epaper.update ();
//Acceder al servidor NTP y obtener fecha y hora actual (en RTC)
configTime(timeZone * 3600, 0, "pool.ntp.org");
// Leer la hora en timeinfo
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 10000)) {
Serial.println("Error al leer RTC");
sleepSeconds (300);
return; //Solo por claridad
}
Serial.println(&timeinfo, "Time: %H:%M:%S");
Serial.println(&timeinfo, "Date: %Y-%m-%d");
// Pasar a epoch
time_t epochNow = mktime(&timeinfo);
// Calcular cuanto falta para la siguiente medianoche
struct tm tomorrow = timeinfo;
// Llevar a medianoche de mañana
tomorrow.tm_sec = 0;
tomorrow.tm_min = 0;
tomorrow.tm_hour = 0;
tomorrow.tm_mday +=1;
time_t epochTomorrow = mktime(&tomorrow);
int32_t seconds2Tomorrow = (int32_t)(epochTomorrow - epochNow);
Serial.print ("Segundos hasta mañana:");
Serial.println (seconds2Tomorrow);
// Reajusto la hora actual en epoch desde medianoche
timeinfo.tm_sec=0;
timeinfo.tm_min=0;
timeinfo.tm_hour=0;
// Pasar a epoch
time_t epochToday = mktime(&timeinfo);
//Establece una fecha para hacer pruebas
//timeinfo.tm_mday=30;
//timeinfo.tm_mon=12-1;
//Acceder al servidor de Nager para obtener el listado de feriados
WiFiClientSecure client;
client.setInsecure(); // HTTPS cifrado, sin validar certificado
HTTPClient http;
if (!http.begin(client, endpoint)) {
Serial.println("http.begin() failed");
sleepSeconds (300);
return; //Solo por claridad
}
int httpCode = http.GET();
Serial.printf("HTTP code: %d\n", httpCode);
if (httpCode != HTTP_CODE_OK) {
Serial.println("Respuesta del server (primeros 200 chars):");
String errBody = http.getString();
Serial.println(errBody.substring(0, 200));
http.end();
sleepSeconds (300);
return; //Solo por claridad
}
// Descargar todo el JSON
String payload = http.getString();
http.end();
Serial.printf("Payload length: %d bytes\n", payload.length());
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("Error al parsear JSON: ");
Serial.println(error.c_str());
sleepSeconds (300);
return; //Solo por claridad
}
//Explorar el JSON calculando diferencia de dias
//El primero que de mayor a 0 es el siguiente
bool found = false;
for (int i = 0; i < doc.size(); i++) {
const char* holiday = doc[i]["date"];
const char* name = doc[i]["name"];
Serial.println(holiday);
Serial.println(name);
int epochDif = stringDateToEpoch (holiday) - epochToday;
Serial.println (epochDif);
if (epochDif >= 0) { //Si es hoy, indica 0 dias
int daysDif = epochDif / 86400; //Seconds in a day
Serial.println (daysDif);
String holidayName = doc[i]["localName"];
// Selecciona tamaño y font para la cantidad de días
epaper.setTextSize (3);
epaper.setTextFont (7);
epaper.drawNumber (daysDif, epaper.width()/2, 220);
epaper.setTextSize (1);
epaper.setFreeFont(&Yellowtail_32); // Seleccionar tipo de letra
//Muestra solo los primeros 30 caracteres
epaper.drawString (quitarAcentos(holidayName.substring (0,38)), epaper.width()/2, 314);
epaper.update ();
found = true;
break;
}
}
if (!found) {
//No hay mas feriados :(
// Selecciona tamaño y font para la cantidad de días
epaper.setTextSize (3);
epaper.setTextFont (7);
epaper.drawString ("--", epaper.width()/2, 220);
epaper.setTextSize (1);
epaper.setFreeFont(&Yellowtail_32); // Seleccionar tipo de letra
epaper.drawString ("No hay mas feriados!", epaper.width()/2, 314);
epaper.update ();
}
//Todo listo, ir a dormir hasta mañana
Serial.println ("A dormir...");
sleepSeconds (seconds2Tomorrow);
}
void loop() {
//No hace nada
}
Configuración para Chile
Dos cambios en el código antes de compilar, en el bloque de localización:
const int timeZone = -4; // Chile continental (UTC-4). En verano, -3.
const char* country = "CL"; // Código de país para Chile
El listado completo de códigos de país está en date.nager.at/Country. Nager cubre los feriados chilenos oficiales (Año Nuevo, Viernes Santo, Día del Trabajo, Glorias Navales, San Pedro y San Pablo, Virgen del Carmen, Asunción, Fiestas Patrias, Encuentro de dos Mundos, Reformación, Día de las Iglesias Evangélicas, Todos los Santos, Inmaculada Concepción, Navidad).
Ojo con el DST: Chile ajusta horario de verano/invierno. Si quieres que el panel siga la hora local siempre, considerá usar la variable TZ con formato POSIX (por ejemplo "CLT4CLST,M9.1.6/24,M4.1.6/24") en configTime en vez del offset fijo.


Cómo pensar el manejo de tiempo
Tres detalles sutiles del código que vale la pena entender bien porque son reutilizables:
1. Diferencia entre epochNow y epochToday: epochNow es el momento exacto (hora, minuto y segundo actuales). epochToday es la medianoche del día actual. Se usa epochToday para calcular la diferencia con los feriados porque si son las 20:00 del jueves y el feriado es el viernes, quieres que la respuesta sea "1 día", no "0.83 días redondeado a 0". Restar contra medianoche del día evita ese error.
2. Cálculo de seconds2Tomorrow: se crea una copia de timeinfo, se ponen las horas/minutos/segundos en cero y se incrementa el día en 1. mktime normaliza automáticamente si el nuevo día "cae" al mes siguiente (ej. 31 diciembre → 1 enero). No hay que manejar overflows a mano.
3. client.setInsecure(): la API de Nager es HTTPS y el ESP32 no trae CA store completo. Con setInsecure() el cliente cifra pero no valida el certificado. Para este caso. datos públicos de feriados. es aceptable. Para APIs que devuelven datos sensibles, carga el CA raíz correspondiente con client.setCACert(...).
Variantes y mejoras
Extensiones concretas que no están en el tutorial original:
- Selección de país con botón físico: agregá un botón GPIO. Al presionar durante el arranque, mostrá un menú simple en pantalla para cambiar el país (ciclando por una lista corta: CL, AR, PE, MX, ES, US). Guardá la selección en NVS para que persista al deep sleep. Útil si quieres regalar el panel a alguien de otro país.
- Segundo feriado en la esquina: además del más próximo, mostrá "y después: [nombre] en N días" en fuente chica. Da más información sin ocupar espacio central.
- Pantalla e-Paper a color: reemplazá la 5,83" mono por una versión de 4 colores (blanco/negro/rojo/amarillo, controlador diferente). Con eso puedes pintar en rojo los feriados de fin de semana largo. Requiere cambiar el
driver.hy algunos parámetros dedrawBitmap. - Adaptación a la placa EE05: la EE04 y EE05 comparten API pero la EE05 tiene el módulo XIAO ESP32-S3 Plus, con más RAM y mejor gestión de batería. Si estás por comprar la placa, la EE05 es la elección más futuro proof.
Personalización para Chile
Todo el hardware necesario existe en el mercado local:
- Placa driver EE04 o EE05 de Seeed Studio con XIAO ESP32-S3. el corazón del proyecto. Buscala en MechatronicStore por "XIAO ePaper" o "EE04/EE05 driver"
- Pantalla e-Paper 5,83" 648×480 monocromo compatible con la EE04
- Batería LiPo 3,7 V 500 mAh (opcional, para operación libre de cables)
- Cable USB-C para programación
Con el consumo típico del proyecto (unos segundos despierto una vez al día), una batería LiPo de 500 mAh puede llegar tranquilamente a 6 meses de autonomía. depende del margen que uses en la lectura y del tiempo real que tarda WiFi en asociarse.
Costo aproximado del panel completo, sin caja impresa en 3D: CLP 40.000 - 55.000.
Recursos
- Tutorial original. Parte 3: El próximo feriado. Panel inteligente con e-Paper. Parte 3. profe Tolocka
- API de feriados: date.nager.at. gratuita, sin autenticación, cobertura mundial
- Librería Seeed_GFX: github.com/Seeed Studio/Seeed_GFX
- Configurador de
driver.h: SenseCraft HMI - Herramienta de dithering para bitmap.h: SenseCraft HMI Dither
Versión chilena inspirada en el proyecto de Ernesto Tolocka, adaptada al ecosistema local y a los feriados de Chile.






