¿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:

  1. El ESP32 arranca (desde reset o desde deep sleep). Se conecta al WiFi.
  2. Sincroniza su RTC interno contra un servidor NTP público.
  3. 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.
  4. Parsea el JSON de respuesta, recorre feriados en orden, calcula la diferencia en días respecto a hoy.
  5. 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.
  6. Calcula cuántos segundos faltan hasta la próxima medianoche y entra en deep sleep hasta ese momento.
  7. 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)

Placa EE04 con XIAO ESP32-S3 montado y pantalla e-Paper 5,83" conectada por el flat de 24 pines

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:

Código
#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

C++
/*
 * 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:

C++
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.

Diagrama del flujo del programa. desde el arranque hasta el deep sleep pasando por WiFi, NTP y la API de Nager

Panel en funcionamiento mostrando el próximo feriado con número grande y nombre en tipografía Yellowtail

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.h y algunos parámetros de drawBitmap.
  • 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

Versión chilena inspirada en el proyecto de Ernesto Tolocka, adaptada al ecosistema local y a los feriados de Chile.