Are you tired from work or from studying? Are you waiting for the next public holiday to rest or practice your favorite sport? There’s no need to keep checking the calendar all the time—this smart e-paper panel, programmed with Arduino, tells you how many days are left until the next public holiday.
IntroductionElectronic paper displays, also known as EPDs, have one feature that makes them truly unique: they only consume power when the image is refreshed. That makes them ideal for displaying information that changes slowly, or does not change at all. Today, they are widely used in information signs, IoT panels, and electronic price tags, both in black-and-white and color versions.
Taking advantage of that special characteristic, in this project I will show you how to build a smart panel that displays how many days are left until the next public holiday or non-working day. At first glance, it may seem like a simple idea, but as we move forward you will see that it brings together several interesting technologies, each with its own small challenge.
How it worksThe main component of the panel is a 5.83-inch monochrome e-Paper display, controlled by an EE04 board based on the XIAO ESP32-S3 Plus module, all from Seeed Studio.
As mentioned before, the main goal of the panel is to show the number of days remaining until the next public holiday. To make that happen, the system has to complete several intermediate steps:
- Know the current date: determine what day “today” is.
- Get a list of public holidays: retrieve the holidays for the current year.
- Find the nearest one: calculate the number of days until each holiday and select the closest.
- Show the result on the EPD: combine graphics and text to present the information in an attractive way.
Let’s take a closer look at each step.
The ESP32-S3 includes an internal RTC (Real Time Clock) that can keep track of the current date and time. However, the EE04 board does not include a backup battery, so that information is lost when power is removed. To solve this, we will use the ESP32-S3 connectivity and fetch the correct time from an NTP server on the Internet every time the panel powers up.
Once the current time is obtained from the NTP server, we will use it to update the internal RTC of the ESP32-S3.
The holiday data could be hardcoded into the sketch as a fixed table, but that would limit the project to just one country, or at best a small group of countries. In this project, we will go a bit further and make it international. For that purpose, we will use Nager.date, a free service that provides a list of public holidays for more than one hundred countries in JSON format through an API. The data is retrieved using an HTTPS GET request.
After downloading the holiday dates, we will determine which one is the closest by comparing them against the current date expressed in epoch format.
Finally, we will display the result on the e-Paper screen using the Seeed_GFX library, which is optimized for this kind of low-power display.
If some of the terms used in the previous description sound unfamiliar, don’t worry. In the next sections, I will explain each one step by step.
NTPNetwork Time Protocol, is the communication protocol that allows a device to access what are known as NTP servers. These servers are distributed all over the world and can provide highly accurate time information, typically with millisecond-level precision.
NTP servers work with UTC time (Coordinated Universal Time), which is the worldwide time reference. However, each country—and in some cases even different regions within the same country—uses a local time that may differ from UTC.
This happens because the Earth is divided into multiple time zones, each defined as an offset relative to UTC. For example, in Argentina we use the UTC-3 time zone, which means our local time is three hours behind UTC. If UTC time is 11:00 AM, then the local time here is 8:00 AM.
The following image, taken from https://www.timeanddate.com/time/map/, shows the different time zones around the world.
Synchronizing the clock
So far, everything looks good—but how do we actually use it in our Next Holiday panel?
Good question. To get the current time from an NTP server in Arduino, we can rely on a very useful library called time, which includes the configTime() function that takes care of the whole process.
The configTime() function is used like this:
configTime (gmtOffset_sec, daylightOffset_sec, ntpServer);gmtOffset_sec: the difference between your local time and UTC, expressed in seconds. It is obtained by multiplying the time zone by 3600.daylightOffset_sec: an additional adjustment for daylight saving time, also expressed in seconds.ntpServer: the NTP server you want to query.
Of course, before trying to reach the NTP server, the board must be connected to WiFi. I will assume you already know how to do that, so I will not go into details here.
The following example connects to WiFi and retrieves the current time from an NTP server:
#include <WiFi.h> // WiFi functions
#include <time.h> // RTC and NTP functions
// WiFi credentials (Replace with yours)
const char* ssid = "yourSSID";
const char* password = "yourPassword";
void setup() {
Serial.begin(115200);
// Connect to the WiFi network
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi connected!");
// Gets the date and time from the NTP server and updates the internal RTC.
// My timezone is UTC-3
// Find yours at https://www.timeanddate.com/time/map/
int timeZone = -3;
configTime(timeZone * 3600, 0, "pool.ntp.org");Reading the Clock
The ESP32’s internal RTC can be read using the getLocalTime() function, passing a tm structure as its argument:
struct tm timeinfo;
getLocalTime(&timeinfo);This structure has the following format:
struct tm {
int tm_sec; // Seconds (0 - 59)
int tm_min; // Minutes (0 - 59)
int tm_hour; // Hours (0 - 23)
int tm_mday; // Day of the month (1 - 31)
int tm_mon; // Month (0 - 11)
int tm_year; // Years since 1900
int tm_wday; // Day of the week (0 - 6, Sunday = 0)
int tm_yday; // Day of the year (0 - 365)
int tm_isdst; // Daylight saving time flag (>0 if active)
};Pay close attention to a few fields:
- The year field (
tm_year) stores the number of years since 1900. So instead of2026, it contains126. - The month field (
tm_mon) starts at 0 (January = 0). - For the day of the week (
tm_wday),Sunday = 0.
With what we’ve covered so far, we can now set the ESP32’s internal RTC with the accuracy of the atomic clocks used by NTP servers.
Now, since the goal is to compare the date stored in that RTC with public holiday dates, we need a simple and reliable way to compare dates. That’s where the epoch format comes in. As you’ll see next, it makes this process much easier.
The EPOCH Format
How can we compare two dates to determine, for example, which one happened first and which one came later?
If we have 2026/02/10 on one side and 2025/11/08 on the other, we need a method that lets us determine their chronological order in a simple way.
A very simple way to do this is to convert both dates to epoch format and compare them as numbers.
Epoch time was introduced in the 1970s as part of the Unix operating system. The idea is brilliant: represent a date as the number of seconds elapsed since a fixed reference point, defined as January 1, 1970 at 00:00:00 (UTC).
Calculating that number is not trivial, since it requires taking into account the number of days in each month, the hours in each day, the seconds in each hour, and leap years as well.
For example, using an epoch calculator such as epochconverter.com, you get these values:
2026/02/10 at 00:00 → 17706816002025/11/08 at 00:00 → 1762560000
Comparing both values, we can see that 1762560000 is smaller than 1770681600, so it corresponds to an earlier date.
Converting to Epoch
In Arduino, the time library includes the mktime() function to convert a date into epoch format.
mktime() expects the date to be stored in a tm structure, like the one we used earlier to read the ESP32 RTC.
The following example converts one of the previous dates to epoch format and prints the result:
#include "time.h"
void setup() {
Serial.begin(115200);
struct tm timeDate;
timeDate.tm_sec = 0;
timeDate.tm_min = 0;
timeDate.tm_hour = 0;
timeDate.tm_mday = 10; // 10
timeDate.tm_mon = 2 - 1; // February
timeDate.tm_year = 2026 - 1900; // 2026
Serial.println(&timeDate, "Date: %Y-%m-%d");
// Convert the structure to epoch
int epoch = mktime(&timeDate);
Serial.println(epoch);
}
void loop() {
// Put your main code here, to run repeatedly:
}In this example, I set the time-related fields to 0 so the date matches midnight. I also intentionally entered the month as 2 - 1 to remind you that January corresponds to 0. For the year, I used 2026 - 1900 so you can remember that this field stores the number of years since 1900.
After running the code, the Serial Monitor shows the following result, which matches what we calculated earlier using the Epoch Converter website.
Date: 2026-02-10
1770681600HTTPS RequestsThe NTP protocol we used earlier is one way to access a server on the Internet, in that case to retrieve time and date information.
There are many other protocols used to exchange different kinds of data. One of the most common is HTTP, which allows us to access websites, retrieve data from them, and even send information back.
The HTTP Protocol
HTTP was born alongside the Web in the early 1990s and defines how data travels between users and servers. It follows a client-server model, where each side has a clear role: the server stores the information, and the client requests it.
Every time we open a webpage, we are using HTTP. The browser acts as the client and sends a request to the server. If everything goes well, the server answers with the requested data—usually a page containing different elements—in what is called a response. If something goes wrong or the information does not exist, the server returns an error message, such as the well-known 404 Not Found.
For this communication to work, the client must know the URL, that is, the server’s address on the Internet, and it must also tell the server what action it wants to perform. For that purpose, HTTP defines several methods, but the two most common are GET and POST.
GET: The client tells the server it wants to receive information.POST: The client tells the server it wants to send information.
AlthoughGETis often used,POSTis the proper choice whenever we need to send data to a server.
Adding Security
HTTP by itself is not secure. If a third party intercepts the communication between the client and the server, the transmitted data can be read very easily. Imagine making a purchase with your credit card—any attacker could steal that sensitive information.
For that reason, the original HTTP protocol was extended, giving rise to HTTPS. As you probably guessed, the extra S stands for Secure. HTTPS is simply HTTP with an added security layer that provides two key features:
- Encryption: Unlike HTTP, which sends data as plain text, HTTPS encrypts the information so that even if it is intercepted, it cannot be used.
- Authentication: The server presents a digital certificate that proves its identity. This ensures that the client is talking to the real site and not an impostor.
Today, most servers use HTTPS instead of plain HTTP. In fact, it is very common for a URL to start with http://, but once accessed, the site automatically redirects the client to its secure https:// version.
Accessing a Server with Arduino
Let’s now see how we can access a server that uses HTTPS to exchange information from an ESP32 using Arduino language. For that, we have two very useful libraries available:
WiFiClientSecure: Establishes the connection to the server using HTTPS. It handles encryption and certificates.HTTPClient: Implements HTTP methods such asGETandPOST. It can be used with both HTTP and HTTPS.
Let’s look at the following example, which opens an HTTPS connection and performs a GET request. To keep things simple, and because we already know the server we are connecting to, we will not use certificates.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
const char* ssid = "yourSSID";
const char* password = "yourPASS";
String url = "https://httpbin.org/get"; // Test site URL
void setup() {
Serial.begin(115200);
// Connect to WiFi
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi connected!");
WiFiClientSecure client; // Create object for the connection (HTTPS)
client.setInsecure(); // Encrypted HTTPS, without certificate validation
HTTPClient http; // Create object to handle HTTP requests (GET/POST)
// Initialize the HTTP/HTTPS request (set up URL + TLS client)
if (!http.begin(client, url)) {
Serial.println("http.begin() failed");
delay(5000);
return;
}
// Send the GET request and obtain the HTTP response code
int httpCode = http.GET();
Serial.printf("HTTP code: %d\n", httpCode); // HTTP status code
if (httpCode != HTTP_CODE_OK) {
// There was some problem
Serial.println("Server response (first 200 chars):");
String errBody = http.getString();
Serial.println(errBody.substring(0, 200));
http.end();
delay(10000);
return;
}
// The request was successful
String payload = http.getString();
http.end();
Serial.printf("Payload length: %d bytes\n", payload.length());
Serial.println("Payload:");
Serial.println(payload);
}
void loop() {
// Nothing to do
}If everything works correctly and no issues show up, you should see something like this in the Serial Monitor:
WiFi connected!
HTTP code: 200
Payload length: 298 bytes
Payload:
{
"args": {},
"headers": {
"Accept-Encoding": "identity;q=1,chunked;q=0.1,*;q=0",
"Host": "httpbin.org",
"User-Agent": "ESP32HTTPClient",
"X-Amzn-Trace-Id": "Root=1-6986747c-3f89c6f816ecac1d6f101d05"
},
"origin": "192.141.210.29",
"url": "https://httpbin.org/get"
}The first message corresponds to a successful Wi-Fi connection. Next, the HTTP status code is displayed, which in this case is 200. This is the same type of code you sometimes see in a browser, such as 404 (Not Found) or 403 (Forbidden). A 200 status means everything is working correctly.
Next, the sketch shows how many bytes were returned by the server as the response (Payload length), and finally it prints the actual content of that response (Payload).
Take a close look at the format used in that response. The text enclosed in {} symbols is the well-known JSON format, which is widely used in IoT. We will dive into it in more detail later.
Going back to the code, let’s break down how it works.
At the beginning, the url variable is declared. It contains the address of the site we will send the request to. For this example, I chose httpbin.org, which is commonly used for testing.
String url = "https://httpbin.org/get"; // Test site URLNext, the sketch connects to the WiFi network. Remember to use your own network name and password.
Later, the client object is created from the WiFiClientSecure class. This object will later be used to establish the HTTPS connection to the server. The next line is very important:
WiFiClientSecure client; // Create object for the connection (HTTPS)
client.setInsecure(); // Encrypted HTTPS, without certificate validationsetInsecure() tells the client object not to validate the server’s digital certificate. This does not mean the connection is no longer encrypted: the data is still transmitted securely, but the client does not verify the server’s identity.
To complete this declaration stage, the http object is created from the HTTPClient class, which will be used to send requests to the server.
HTTPClient http; // Create object to handle HTTP requests (GET/POST)Now it is time for action. The following block prepares the HTTPS request and checks whether it could be initialized correctly before sending anything to the server. If there is a problem—such as no WiFi connection or an invalid URL—it prints an error message, waits a few seconds to avoid retrying immediately, and then stops the program.
// Initialize the HTTP/HTTPS request (set up URL + TLS client)
if (!http.begin(client, url)) {
Serial.println("http.begin() failed");
delay(5000);
return;
}If this initialization finishes without errors, the request is finally sent to the server. The GET() method of the http object sends the request and returns the HTTP response status code, which is stored in the httpCode variable.
// Send the GET request and obtain the HTTP response code
int httpCode = http.GET();
Serial.printf("HTTP code: %d\n", httpCode); // HTTP status codeAs we saw earlier, this status code tells us the result of the request. If everything went well, its value will be 200. If something failed, it will return a different value.
Next, the code checks exactly that: whether the request was successful and returned 200 (here, the constant HTTP_CODE_OK is used instead, which has the same value).
If an error occurred, the first 200 characters of the response are printed—since it may be quite long—the connection is closed using the end() method, and the program stops.
If there was no error, the server response is read using http.getString(), the connection is closed, and both the response and its length are displayed.
It is important to distinguish between the status code and the response body itself. The first tells us whether the request succeeded or failed; the second contains the actual information returned by the server.
if (httpCode != HTTP_CODE_OK) {
// There was some problem
Serial.println("Server response (first 200 chars):");
String errBody = http.getString();
Serial.println(errBody.substring(0, 200));
http.end();
delay(10000);
return;
}
// The request was successful
String payload = http.getString();
http.end();
Serial.printf("Payload length: %d bytes\n", payload.length());
Serial.println("Payload:");
Serial.println(payload);The format of the response depends on the server. In this case, it replies in JSON format, so I’ll explain what that means in the next few paragraphs.
JSONJSON stands for JavaScript Object Notation. It is a standard format for exchanging information using plain text, which makes it lightweight, easy for humans to read, and simple for programs to process.
It is widely used on the Internet to connect applications and web services, and it is also very common in IoT projects, where devices and servers need to exchange data in a simple and efficient way.
Objects and Arrays
A text or file in JSON format is made up of one or more objects. An object contains properties, which are key : value pairs separated by a colon (:).
For example, the following is an object containing a single property (name):
{
"name": "John"
}The following is another object containing three properties (name, age, and height):
{
"name": "Ana",
"age": 25,
"height": 1.67
}As you can see, each property is separated by a comma, and the object itself is enclosed in curly braces.
How could we represent information for multiple people in a JSON file? The answer is: by using an array of objects.
[
{
"name": "Ana",
"age": 25,
"height": 1.67
},
{
"name": "John",
"age": 30,
"height": 1.80
}
]This JSON structure is an array, enclosed in square brackets[ ], containing two objects. Each object has three properties.
More generally, we can say that a JSON file is made up of objects or arrays of objects.
ArduinoJSON
There are several libraries available in Arduino for working with JSON files and manipulating their contents. One of the most popular is ArduinoJSON.
ArduinoJSON stands out for being lightweight, fast, and especially well suited for microcontrollers with limited resources, such as the ESP32. Among other things, it allows you to:
- Read (parse) JSON received from the Internet or from memory
- Access objects and arrays easily
- Create JSON to send to servers
- Work with both small files and more complex structures
- Optimize RAM usage, which is critical in microcontrollers
Thanks to these features, it has become a de facto standard in IoT projects, where JSON-based data exchange is extremely common.
You can easily install the latest version of ArduinoJSON in the Arduino IDE from the Library Manager.
Serialization
Serialization is the process of converting internal data or program variables into JSON text format.
With ArduinoJSON, this can be done very easily. Take a look at the following example:
#include "ArduinoJson.h"
void setup() {
JsonDocument doc; // Create the object
Serial.begin(115200);
// Load values
doc["name"] = "Ana";
doc["age"] = 25;
doc["height"] = 1.67;
// Serialize
String docJson;
serializeJson(doc, docJson);
Serial.println(docJson);
}
void loop() {
// Put your main code here, to run repeatedly:
}In the code above, the first step is to include the ArduinoJson.h library. Then, the doc object is created from the JsonDocument class. This object will be used to store the values that will later be converted to JSON format.
Values are loaded into the object in a very simple way by referring to each key, like this:
doc["name"] = "Ana";Finally, a String variable is declared to store the JSON text, and the contents of the doc object are serialized into it.
The result can be seen by printing the String to the Serial Monitor:
{"name":"Ana","age":25,"height":1.67}That String is already in JSON format, so it can be sent to a server on the Internet, saved to an SD card, or used in any other way your project requires.
Another option is to serialize the JSON directly to the Serial Monitor instead of storing it in a variable. That looks like this:
// Load values
doc["name"] = "Ana";
doc["age"] = 25;
doc["height"] = 1.67;
// Serialize
String docJson;
serializeJson(doc, Serial);This produces exactly the same result on the Serial Monitor:
{"name":"Ana","age":25,"height":1.67}And if you want the JSON in a more readable format, you can use serializeJsonPretty():
// Load values
doc["name"] = "Ana";
doc["age"] = 25;
doc["height"] = 1.67;
// Serialize
String docJson;
serializeJsonPretty(doc, Serial);That produces the following output, which is much easier to read:
{
"name": "Ana",
"age": 25,
"height": 1.67
}The instruction JsonDocument doc creates an object that acts as a container for the values that will later be converted to JSON format during serialization. This object occupies RAM memory.
A declaration like this is called dynamic allocation, and it allows the library to manage memory assignment for that object automatically. That is usually not a problem on microcontrollers with plenty of RAM, such as an ESP32-S3, but it can become an issue on boards where memory is more limited.
If you are working with a microcontroller that has very little RAM, it is often better to use static allocation with StaticJsonDocument<bytes>. For example:
StaticJsonDocument<128> doc;In this example, 128 bytes are reserved for the doc object. This gives you finer control over memory usage, but it also requires a good estimate of how many bytes will actually be needed.
Deserialization
This is the reverse process: it takes a JSON-formatted text file and converts it into internal variables.
#include "ArduinoJson.h"
void setup() {
JsonDocument doc; // Create the object
Serial.begin(115200);
// Create test JSON
String payload = "{\"name\":\"John\",\"age\":30}";
// Deserialize
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("Error: ");
Serial.println(error.c_str());
return;
}
const char* name = doc["name"];
int age = doc["age"];
Serial.println(name);
Serial.println(age);
}
void loop() {
// Put your main code here, to run repeatedly:
}In this example, we also create a doc object from the JsonDocument class. For testing purposes, we load the payload variable with a text string in JSON format. In a real project, this payload would usually come from a server.
deserializeJson() converts that text into internal variables and returns an error code.
This error code is a special object from the ArduinoJson library, and to print it in a readable way, we use the c_str() method.
If no error occurs, the code retrieves the values associated with the keys "name" and "age" and then prints them to the Serial Monitor.
When you run this example, the Serial Monitor shows:
John
30In our Next Holiday, we will send a request to a server asking for a list of all public holidays, which will be returned in JSON format. Our job will be to deserialize that JSON and extract the information we need—such as the date of each holiday—in order to determine which one is closest to the current date.
The codeLet’s now look at the project code and how it works. The general idea can be represented with the following flowchart:
This is the complete code for the panel. It is a bit long, but don’t worry. We’ll go through it section by section below.
/*
* Author: Ernesto Tolocka (Profe Tolocka)
* Date: 2026-Apr-11
* Description: Gadget that shows how many days remain until the next public holiday.
* Notes: Uses the EE04 board and a 5.83-inch monochrome EPD
*
* License: MIT
*/
#include <WiFi.h>
#include <time.h>
#include "TFT_eSPI.h"
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h> // Used to parse JSON
#include <esp_sleep.h> // For deep sleep
#include "bitmap.h" // Background image
EPaper epaper;
// Wi-Fi network name and password
const char* ssid = "yourSSID";
const char* password = "yourPassword";
// Location
// Modify these values according to your country
const int timeZone = -3; // Local timezone
const char* country = "AR"; // Country code. See https://date.nager.at/Country
// Date.Nager endpoint
String endpoint = String("https://date.nager.at/api/v3/PublicHolidays/2026/") + country;
// Function to convert a text-format date to epoch
time_t stringDateToEpoch (const char* stringDate) {
// Converts a string like 2026-02-02 to epoch
int year,month, day;
struct tm timeDate = {}; // Start empty
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");
// Convert the structure to epoch
return (mktime(&timeDate));
}
// Function to remove accents
String removeAccents (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;
}
// Function to put the ESP32 into deep sleep
void sleepSeconds (uint32_t s) {
if (s < 60) s = 60; // Just in case
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
esp_sleep_enable_timer_wakeup((uint64_t)s * 1000000ULL);
esp_deep_sleep_start();
}
// Function to connect to WiFi with 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 did not connect. Going to sleep for 5 min.");
sleepSeconds(300);
return;
}
Serial.println("WiFi connected!");
// Configure and initialize display
epaper.begin();
epaper.fillScreen(TFT_WHITE); // Clear screen
epaper.setFreeFont(&Yellowtail_32); // Select font
epaper.setRotation(0);
epaper.setTextDatum(MC_DATUM); // Set centered alignment
epaper.setTextSize (1);
epaper.drawString ("Days Until Next Holiday: ", epaper.width()/2,98);
// Load background image
epaper.drawBitmap(0, 0, holidayBack, 648, 480, TFT_BLACK);
// Show everything
epaper.update ();
// Access NTP server and get current date and time (in RTC)
configTime(timeZone * 3600, 0, "pool.ntp.org");
// Read time into timeinfo
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 10000)) {
Serial.println("Error reading RTC");
sleepSeconds (300);
return; // Only for clarity
}
Serial.println(&timeinfo, "Time: %H:%M:%S");
Serial.println(&timeinfo, "Date: %Y-%m-%d");
// Convert to epoch
time_t epochNow = mktime(&timeinfo);
// Calculate how long until next midnight
struct tm tomorrow = timeinfo;
// Move to tomorrow's midnight
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 ("Seconds until tomorrow:");
Serial.println (seconds2Tomorrow);
// Readjust current time in epoch from midnight
timeinfo.tm_sec=0;
timeinfo.tm_min=0;
timeinfo.tm_hour=0;
// Set a date for testing
//timeinfo.tm_mday=30;
//timeinfo.tm_mon=12-1;
// Convert to epoch
time_t epochToday = mktime(&timeinfo);
// Access Nager server to get the list of holidays
WiFiClientSecure client;
client.setInsecure(); // Encrypted HTTPS, certificate not validated
HTTPClient http;
if (!http.begin(client, endpoint)) {
Serial.println("http.begin() failed");
sleepSeconds (300);
return; // Only for clarity
}
int httpCode = http.GET();
Serial.printf("HTTP code: %d\n", httpCode);
if (httpCode != HTTP_CODE_OK) {
Serial.println("Server response (first 200 chars):");
String errBody = http.getString();
Serial.println(errBody.substring(0, 200));
http.end();
sleepSeconds (300);
return; // Only for clarity
}
// Download the entire 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 parsing JSON: ");
Serial.println(error.c_str());
sleepSeconds (300);
return; // Only for clarity
}
// Iterate through the JSON calculating day difference
// The first one greater than 0 is the next holiday
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) { // If it is today, show 0 days
int daysDif = epochDif / 86400; // Seconds in a day
Serial.println (daysDif);
String holidayName = doc[i]["name"]; // Use "name" or "localName"
// Select size and font for number of days
epaper.setTextSize (3);
epaper.setTextFont (7);
epaper.drawNumber (daysDif, epaper.width()/2, 220);
epaper.setTextSize (1);
epaper.setFreeFont(&Yellowtail_32); // Select font
// Show only the first 38 characters
epaper.drawString (removeAccents(holidayName.substring (0,38)), epaper.width()/2, 314);
epaper.update ();
found = true;
break;
}
}
if (!found) {
// There are no more holidays :(
// Select size and font for number of days
epaper.setTextSize (3);
epaper.setTextFont (7);
epaper.drawString ("--", epaper.width()/2, 220);
epaper.setTextSize (1);
epaper.setFreeFont(&Yellowtail_32); // Select font
epaper.drawString ("No More Holidays!", epaper.width()/2, 314);
epaper.update ();
}
// Everything is ready, go to sleep until tomorrow
Serial.println ("Going to sleep...");
// Delay to allow access from the IDE if necessary.
delay (10000);
sleepSeconds (seconds2Tomorrow);
}
void loop() {
// Does nothing
}Initialization
In the first few lines, as usual, we have the #include directives to add all the required libraries. The file bitmap.h is also included, which contains the panel’s background image.
To control the EPD, we will use the Seeed_GFX library, because it is optimized for the EE04 board and the display we are going to use.
#include <WiFi.h>
#include <time.h>
#include "TFT_eSPI.h"
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h> // Used to parse JSON
#include <esp_sleep.h> // For deep sleep
#include "bitmap.h" // Background imageNext, the epaper object is created to control the display, and the credentials needed to connect to Wi-Fi are defined, namely the network name and password. Remember to replace yourSSID with your network name and yourPassword with its password.
EPaper epaper;
// Wi-Fi network name and password
const char* ssid = "yourSSID";
const char* password = "yourPassword";Then, two values related to your location are defined, and you will need to change them according to the country where you live.
timezone is your time zone, and you can find it on this site. country contains a country code used by the Nager.date service to provide the list of public holidays for that specific country. The list of available countries and their corresponding codes can be found on this page.
In my case, timeZone = -3 and the country code is AR (Argentina).
// Location
// Modify these values according to your country
const int timeZone = -3; // Local timezone
const char* country = "AR"; // Country code. See https://date.nager.at/CountryInitialization ends by defining the endpoint. An endpoint is the specific URL used to make requests to a website. It is the exact address of a resource or service within a server: not just the website itself, but the precise point where our application sends data or requests information.
According to the Nager documentation, the endpoint has this format:
https://date.nager.at/api/v3/publicholidays/{Year}/{Country code}Therefore, it is defined as a fixed string that includes the year (2026), and then the country code defined a few lines earlier is concatenated to it.
// Date.Nager endpoint
String endpoint = String("https://date.nager.at/api/v3/PublicHolidays/2026/") + country;Function definitions
Next, the code defines four functions:
stringDateToEpoch: converts a date string, such as2026-2-16, into epoch format.removeAccents: replaces accented vowels with non-accented ones, and also replacesñwithn, because those characters are not defined in the font used to display text on the screen.sleepSeconds: puts the ESP32 into deep sleep mode for a specified number of seconds.connectWiFi: connects to the Wi-Fi network with a timeout.
As we use them, we will take a closer look at what each of these functions does.
Wi-Fi connection
Now inside setup(), the next step is to connect to the Wi-Fi network. This is done by calling the connectWiFi function defined earlier with a timeout of 30 seconds. If the connection is successful, the message “WiFi connected” is displayed. But if the connection is not established before the timeout expires, the ESP32 is put into low-power mode for 5 minutes (300 seconds), so it can restart and try again.
This same strategy will be used later in other situations that may lead to an error condition.
The return statement that closes the block is never actually reached, but it is included for completeness.
if (!connectWiFi(30000)) {
Serial.println("WiFi did not connect. Going to sleep for 5 min.");
sleepSeconds(300);
return;
}
Serial.println("WiFi connected!")EPD initialization
The next step is to initialize the e-Paper display, clear any content that may still be on the screen, select the Yellowtail_32 font, display the text “Days Until the Next Public Holiday:”, and load the background image.
// Configure and initialize display
epaper.begin();
epaper.fillScreen(TFT_WHITE); // Clear screen
epaper.setFreeFont(&Yellowtail_32); // Select font
epaper.setRotation(0);
epaper.setTextDatum(MC_DATUM); // Set centered alignment
epaper.setTextSize (1);
epaper.drawString ("Days Until Next Holiday: ", epaper.width()/2,98);
// Load background image
epaper.drawBitmap(0, 0, holidayBack, 648, 480, TFT_BLACK);
// Show everything
epaper.update ();The background image is a 648 × 480 pixel monochrome bitmap (the same size as the display), created using AI and then edited to adjust some details.
To load it from the code, it must first be converted into a .h file containing the information for each pixel.
There are several tools available for this, but one of the most complete and easiest to use is the one included in SenseCraft HMI by Seeed Studio, which is free.
The link to the conversion tool ishttps://sensecraft.seeed.cc/hmi/tools/dither. If it does not work, try creating an account athttps://sensecraft.seeed.cc/hmiand then look for the Tools option.
This is the configuration I used:
When you click the Generate Header button, the file we need to include is generated. The tool always adds the letter “e” at the beginning of the name we chose in the configuration.
Finally, we need to add this file to our project in the Arduino IDE:
The image is loaded using the drawBitmap function, specifying the origin coordinates (0,0), the name of the array containing the image definition (holidayBack), the image size (648 x 480), and the pixel color (TFT_BLACK).
// Load background image
epaper.drawBitmap(0, 0, holidayBack, 648, 480, TFT_BLACK);If you want to learn more about using monochrome and color images on e-Paper displays, check out this tutorial I published earlier.
Remember that for Seeed_GFX to work correctly with the display, it is necessary to generate the driver.h file beforehand using its online configuration tool.
#define BOARD_SCREEN_COMBO 503 // 5.83 inch monochrome ePaper Screen (UC8179)
#define USE_XIAO_EPAPER_DISPLAY_BOARD_EE04RTC synchronization
In this part of the code, several tasks related to the RTC and the date/time information are performed.
The first step is to set the ESP32’s internal RTC using an NTP server as a reference. This is done with the configTime instruction, as we already saw.
// Access NTP server and get current date and time (in RTC)
configTime(timeZone * 3600, 0, "pool.ntp.org");Next, the date and time are read from the RTC and stored in the timeinfo structure. If an error occurs during this process, as we did before, the ESP32 is put into deep sleep for 5 minutes and then restarted.
If everything works correctly, the date and time are shown on the serial monitor as a check, and then this information is converted to epoch format in the variable epochNow so it can be processed more easily.
// Read time into timeinfo
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 10000)) {
Serial.println("Error reading RTC");
sleepSeconds (300);
return; // Only for clarity
}
Serial.println(&timeinfo, "Time: %H:%M:%S");
Serial.println(&timeinfo, "Date: %Y-%m-%d");
// Convert to epoch
time_t epochNow = mktime(&timeinfo);Next, the code calculates how many seconds remain from the current moment until midnight of the following day.
This is done so that, once the whole process of checking the holiday list and displaying the results is finished, the ESP32 can enter low-power mode until that moment. In this way, the panel will operate for only a few seconds every 24 hours, at midnight, to update the information, and will remain “asleep” the rest of the time, reducing power consumption to a minimum.
The information will remain visible on the screen thanks to the bistable nature of e-Paper.
// Calculate how long until next midnight
struct tm tomorrow = timeinfo;
// Move to tomorrow's midnight
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 ("Seconds until tomorrow:");
Serial.println (seconds2Tomorrow);This calculation works as follows:
It starts with two identical tm structures, both containing date and time information. tomorrow initially contains the same data as timeinfo, that is, the current date and time.
Then tomorrow is modified. The hour, minute, and second values are set to 0, and the day is increased by one. This turns tomorrow into the next midnight.
Then tomorrow is converted to epoch format in epochTomorrow (timeinfo was already converted to epochNow), and the difference in seconds between the two is calculated, that is, between “now” and tomorrow’s midnight. That is the value that will later be used to configure the timer that will “wake up” the ESP32 and bring it out of deep sleep mode.
Finally, the variable epochToday is created from timeinfo, but with the hour, minute, and second values set to 0, so that time is counted starting from midnight of the current day rather than from the moment the device was turned on.
This is done so that, when the difference with the holiday dates is calculated, the current day counts as a full day, and if the holiday occurs the next day, the difference will be one day.
// Readjust current time in epoch from midnight
timeinfo.tm_sec=0;
timeinfo.tm_min=0;
timeinfo.tm_hour=0;
// Set a date for testing
//timeinfo.tm_mday=30;
//timeinfo.tm_mon=12-1;
// Convert to epoch
time_t epochToday = mktime(&timeinfo);Connection to the server
Here, the HTTPS connection to the server is established and everything is prepared to make the GET request to the Nager server.
If no errors occur, by the end of the process the variable payload contains the server response, which is the information for all public holidays of the year for the country we specified earlier. This information is in JSON format.
// Access Nager server to get the list of holidays
WiFiClientSecure client;
client.setInsecure(); // Encrypted HTTPS, certificate not validated
HTTPClient http;
if (!http.begin(client, endpoint)) {
Serial.println("http.begin() failed");
sleepSeconds (300);
return; // Only for clarity
}
int httpCode = http.GET();
Serial.printf("HTTP code: %d\n", httpCode);
if (httpCode != HTTP_CODE_OK) {
Serial.println("Server response (first 200 chars):");
String errBody = http.getString();
Serial.println(errBody.substring(0, 200));
http.end();
sleepSeconds (300);
return; // Only for clarity
}
// Download the entire JSON
String payload = http.getString();
http.end();
Serial.printf("Payload length: %d bytes\n", payload.length());Finding the next holiday
We are almost at the end now 💪
In this final part, the server response is examined to find which public holiday is the closest upcoming one.
As I already mentioned, the response is in JSON format and looks more or less like this. I am showing only part of the holidays for my country in 2026.
[
{
"date": "2026-01-01",
"localName": "Año Nuevo",
"name": "New Year's Day",
"countryCode": "AR",
"fixed": false,
"global": true,
"counties": null,
"launchYear": null,
"types": [
"Public"
]
},
{
"date": "2026-02-16",
"localName": "Carnaval",
"name": "Carnival",
"countryCode": "AR",
"fixed": false,
"global": true,
"counties": null,
"launchYear": null,
"types": [
"Public"
]
}, ....
{
"date": "2026-12-25",
"localName": "Navidad",
"name": "Christmas Day",
"countryCode": "AR",
"fixed": false,
"global": true,
"counties": null,
"launchYear": null,
"types": [
"Public"
]
}
]The first thing to notice is that the text starts and ends with square brackets ([ ]), which means we are dealing with an array of objects. Each object represents a public holiday and contains several properties. The last property, types, is itself another array.
The meaning of each property is explained in the Nager documentation. For this project, we are interested only in the first three:
date — The holiday datelocalName — Holiday name in the local languagename — Holiday name in English
The date is essential to identify the next holiday. The name in the local language seems like the best option to display on the screen, but it has some drawbacks. Finally, the English name is useful if we want to make an international version of the panel.
Before exploring the contents of this file, the first thing we need to do is deserialize it, that is, convert it from text into an internal variable. This is done as follows:
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("Error parsing JSON: ");
Serial.println(error.c_str());
sleepSeconds (300);
return; // Only for clarity
}After deserialization, the doc variable contains all the holiday information.
Now it is time to examine the holidays one by one. As we already saw, this array is indexed like any other array in C/C++.
For example, doc[0] contains the first object, that is:
{
"date": "2026-01-01",
"localName": "Año Nuevo",
"name": "New Year's Day",
"countryCode": "AR",
"fixed": false,
"global": true,
"counties": null,
"launchYear": null,
"types": ["Public"]
}Each property inside the object can be referenced by its key, so doc[0]["date"] returns:
"2026-01-01"To iterate through the entire array, we can use a for loop like this:
// Explore the JSON and calculate the difference in days
// The first one greater than 0 is the next holiday
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);size is a method that returns the number of objects inside doc. Inside the loop, the values of date and name are retrieved from each object and shown on the serial monitor.
Within that same for loop, epochDif is calculated, which is the difference, in epoch format, between the holiday date and today’s date. If this difference is less than 0, it means the holiday is already in the past, so it is no longer relevant. But if the difference is greater than or equal to zero, it means the holiday is still in the future or is actually today.
In either case, the difference between the two dates is calculated in days by dividing the seconds in epochDif by 86400, which is the number of seconds in one day, and the Name of that holiday is retrieved. Then the number of days is printed on the EPD in large digits, and the holiday name is displayed in a smaller font.
There is one small issue here. The fonts defined in the Seeed_GFX library are not international, so they do not include letters with accents or other language-specific symbols. If we print the text contained in localName, those characters simply will not appear.
This can be solved in different ways. You could create your own font with all the characters you need, use the English name instead, or remove those symbols from the characters before printing them using the removeAccents function defined at the beginning.
There is also another small issue: the holiday name may be too long. To keep things simple, I chose to print only the first 38 characters, which are the ones that fit on a single line with the font I selected. The ideal solution would be to write a function that prints the text on two or more lines without breaking words, but I will leave that improvement as a challenge for you.
int epochDif = stringDateToEpoch (holiday) - epochToday;
Serial.println (epochDif);
if (epochDif >= 0) { // If it is today, show 0 days
int daysDif = epochDif / 86400; // Seconds in a day
Serial.println (daysDif);
String holidayName = doc[i]["name"]; // Use "name" or "localName"
// Select size and font for number of days
epaper.setTextSize (3);
epaper.setTextFont (7);
epaper.drawNumber (daysDif, epaper.width()/2, 220);
epaper.setTextSize (1);
epaper.setFreeFont(&Yellowtail_32); // Select font
// Show only the first 38 characters
epaper.drawString (removeAccents(holidayName.substring (0,38)), epaper.width()/2, 314);
epaper.update ();
found = true;
break;Before searching for the closest upcoming holiday, the boolean variable found is created and initialized to false. If the next holiday is found, after updating the screen this variable is set to true, and the program exits the for loop.
Next, the value of found is checked. If it is still false, it means no upcoming holiday was found. This can happen when the year is almost over and there are no more holidays left in the calendar. In that special case, the program shows -- for the number of days remaining and the text No More Holidays!
if (!found) {
// No more holidays :(
// Select size and font for the number of days
epaper.setTextSize (3);
epaper.setTextFont (7);
epaper.drawString ("--", epaper.width()/2, 220);
epaper.setTextSize (1);
epaper.setFreeFont(&Yellowtail_32); // Select font
epaper.drawString ("No hay mas feriados!", epaper.width()/2, 314);
epaper.update ();
}The final step is to program the timer so the ESP32 wakes up at midnight tomorrow and then put it to sleep.
// Everything is ready, go to sleep until tomorrow
Serial.println ("Going to sleep...");
// Delay to allow access from the IDE if necessary.
delay (10000);
sleepSeconds (seconds2Tomorrow);Some images of the finished panel:
Conclusions
We have finished the Next Holiday smart panel project. It is a simple, useful, and friendly build that gave us a great excuse to explore some of the most common techniques used in IoT projects that interact with web servers: handling date and time, accessing servers through HTTP and HTTPS, making GET requests, and processing JSON data.
All of this knowledge will be very useful in a wide variety of projects, where you will most likely end up using one or more of these techniques.
We also saw how to use an e-Paper display together with the EE04 controller board from Seeed Studio, how to show text with different fonts, and how to display bitmap images, using the Seeed_GFX library throughout the project.
I hope you enjoyed this project and learned something new. If you did, share it.
See you next time! 🚀







Comments