This project is an example of how to apply the concepts we covered in the previous parts of this tutorial. You can check them at the following links:
IntroductionTo build a clock that shows both the time and date, you need a Real Time Clock (RTC)—that’s the hardware in charge of keeping track of seconds, minutes, hours, days, months, and even leap years.
RTCs can come as external modules or be built right into your microcontroller. For example, the ESP32 features an internal RTC that keeps running even when the CPU is in deep sleep mode.
No matter if it’s built-in or an external chip, all RTCs have one thing in common: they need a backup power source—usually a small coin cell battery—to keep counting time when the main system power is off. And of course, you’ll need a way to set the correct time.
But for this project, let’s take a different approach. Instead of relying on a backup battery to keep the clock running, we’ll use an Internet connection to sync with an NTP server.
That way, even if the device loses track of time when it’s powered off, every time it turns on it will automatically fetch the correct date and time, update the ESP32’s internal RTC, and display the information on the EPD screen.
What makes it possible for a device like the ESP32 to get the exact current date and time is the Network Time Protocol (NTP).
NTP is a communication protocol that lets you access NTP servers—machines distributed all around the world that can provide the exact time with millisecond precision.
To get the time from an NTP server, the ESP32 first needs to connect to the Internet, typically over Wi-Fi.
Once it’s online, the ESP32 communicates with the server using the NTP protocol, which defines how to exchange date and time information accurately and reliably.
After receiving the data, the ESP32 updates its internal RTC, so it can keep track of time locally without needing to reconnect to the server each time.
NTP servers use what’s called UTC (Coordinated Universal Time), which is the worldwide reference for timekeeping. However, each country—and even different regions within a country—can have their own local time. That’s because the world is divided into multiple time zones, each defined as an offset from UTC.
For example, in my country we use the UTC-3 time zone, which means our local time is three hours behind UTC. So if it’s 11:00 AM UTC, it’s 8:00 AM here.
You can check out the different time zones on this page: https://www.timeanddate.com/time/map/
Syncing the ClockNow that you know what an NTP server is and why it’s useful, let’s take a closer look at how to access one and sync the ESP32’s internal RTC.
WiFi ConnectionFirst, we need to connect the ESP32 to a Wi-Fi network. For this, you’ll use a few functions from the WiFi library, plus your network credentials (the SSID and password).
Here’s a sample code snippet for connecting:
#include <WiFi.h> // Functions for WiFi connection
// Wi-Fi network configuration
const char* ssid = "yourSSID"; // Use your network name
const char* password = "yourPassword"; // Use your password
void setup() {
Serial.begin(115200);
// Attempt to connect to WiFi
Serial.println("Connecting to WiFi...");
WiFi.begin(ssid, password);
// Wait until connected
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
// Successful connection
Serial.println("");
Serial.print("Connected to: ");
Serial.println(WiFi.SSID());
}In this example, WiFi.begin starts the connection process using your credentials.
This runs in the background and tries to connect. To check if it was successful, use WiFi.status(). If it returns WL_CONNECTED, you’re in! (For a more robust implementation, you should add a timeout in case it can’t connect after a while)
When the connection is successful, the code prints out the name of the connected network.
Getting Time from NTPOnce Wi-Fi is up and running, you need to reach out to the NTP server to get the current date and time.
You do this with the configTime function from the time library:
#include <time.h> // ESP32 internal RTC functions
// Wifi connection
int timeZone = -3;
configTime(timeZone * 3600, 0, "pool.ntp.org");Note: This snippet isn’t a full sketch, just a code fragment!
The configTime function works like this:
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);- gmtOffset_sec: The difference between your local time and UTC, in seconds. (Multiply your timezone by 3600.)
- daylightOffset_sec: Extra adjustment for daylight saving time, in seconds.
- ntpServer: The NTP server to use.
There are lots of NTP servers out there—find a list at ntppool.org.
configTime works in the background: it sends the request to the NTP server, and once the update is complete, your ESP32’s internal RTC is synced with the current time.
At this point, you could even disconnect from Wi-Fi if you want—the RTC will keep the right time from now on.
Reading Date and TimeYou can read the ESP32’s internal RTC using the getLocalTime function and a tm struct:
struct tm timeinfo;
getLocalTime(&timeinfo);This struct looks like this:
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 month (1 - 31)
int tm_mon; // Month (0 - 11)
int tm_year; // Years since 1900
int tm_wday; // Day of week (0 - 6, Sunday = 0)
int tm_yday; // Day of year (0 - 365)
int tm_isdst; // Daylight Saving Time flag (>0 if active)
};A couple of things to watch out for:
- The year field (tm_year) gives the number of years since 1900. So for “2026” you’ll get “126”.
- The month (tm_mon) starts at 0 (January = 0).
- For the day of week (tm_wday), Sunday = 0.
Here’s a full example that shows the current date and time:
#include <WiFi.h> // WiFi functions
#include <time.h> // ESP32 internal RTC functions
// WiFi network credentials (change these)
const char* ssid = "yourSSID";
const char* password = "yourPassword";
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!");
// Get the time from the NTP server and update the internal RTC
// My time zone is UTC-3
// Find yours at: https://www.timeanddate.com/time/map/
int timeZone = -3;
configTime(timeZone * 3600, 0, "pool.ntp.org");
// Read the time from the internal RTC
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
Serial.println("Failed to obtain time");
return;
}
Serial.println(&timeinfo, "Time: %H:%M:%S");
Serial.println(&timeinfo, "Date: %Y-%m-%d");
}
void loop() {
// Nothing here, the time is already synchronized
}Monochrome DisplayFor this project, we’ll use a monochrome EPD(actually, it supports four levels of gray), which is perfect for exploring one of its most interesting features: partial refresh. This technique is especially handy for applications where the displayed information changes frequently—like a clock!
This particular display has a resolution of 648 x 480 pixels and measures 5.83 inches, so you’ll have plenty of space to show all your data clearly.
Besides supporting partial refresh, it also features a full refresh time of just 3.5 seconds. As we discussed earlier, monochrome EPDs are much faster than color ones because the pigment movement inside the panel is simpler.
Displaying BitmapsIn a previous project, when we looked at how to show bitmap images, I mentioned that Seeed_GFX offers two main functions for this: pushImage and drawBitmap.
The first one, which we’ve already used, is designed for color images. The second one is perfect for images where all the pixels share the same color—like simple icons or logos.
In this project, we’ll add a decorative image at the bottom of the screen. Since all its pixels will be black, the drawBitmap function is the ideal choice here.
The image is 648 pixels wide and 211 pixels high, with a color depth of 1 bpp (bit per pixel):
To display it, we’ll use the following format for the drawBitmap function:
epaper.drawBitmap(posx, posy, bitmap, width, height, color);Where posx and posy set the position of the upper-left corner of the image, bitmap is the name of the array holding the pixel data, width and height are the image dimensions, and color sets the color for all pixels.
You can generate the bitmap array just like before, using Sense Craft HMI. Below is the configuration I used.
One important note: I asked the tool to invert the image colors, so in the generated array, white is represented by “0” and black by “1”. Since the image is 1 bpp, each bit in the array matches a pixel on the display.
The resulting array looks like this:
Now, let’s bring together all the parts we’ve covered so far: the NTP server, the RTC, and the bitmap.
Here’s what the first version of the Internet clock code looks like:
#include "TFT_eSPI.h"
#include "bitmap.h"
EPaper epaper;
#include <WiFi.h>
#include <time.h>
const char* ssid = "yourSSID";
const char* password = "yourPassword";
const char* months [] = {
"January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"
};
char buffer1[6]; // "HH:MM" + null terminator
char buffer2[15]; // Month name and day of month
int lastMinute = -1;
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!");
// Time zone configuration
configTime(-3 * 3600, 0, "pool.ntp.org");
// Initialize the EPD and configure it
epaper.begin();
epaper.fillScreen(TFT_WHITE); // Clear the screen
epaper.setFreeFont(&Orbitron_Light_32); // Select font
epaper.setRotation(0);
epaper.setTextDatum(MC_DATUM); // Center text
// Display background bitmap
epaper.drawBitmap(0, 270, bottom, 648, 211, TFT_BLACK);
}
void loop() {
struct tm timeinfo;
if (getLocalTime(&timeinfo)) {
if (timeinfo.tm_min != lastMinute) {
Serial.println("Time to update");
Serial.println(&timeinfo, "Time: %H:%M:%S");
Serial.println(&timeinfo, "Date: %Y-%m-%d");
int hour = timeinfo.tm_hour;
int minutes = timeinfo.tm_min;
int dayMonth = timeinfo.tm_mday;
int month = timeinfo.tm_mon;
lastMinute = minutes;
// Clear previous text
epaper.fillRect(0, 60, epaper.width(), 120, TFT_WHITE);
// Display time with large digits
epaper.setTextSize(4);
sprintf(buffer1, "%02d:%02d", hour, minutes);
epaper.drawString(buffer1, epaper.width() / 2, 100);
// Display date with smaller characters
epaper.setTextSize(2);
// Clear previous text
epaper.fillRect(50, 250, 560, 70, TFT_WHITE);
sprintf(buffer2, "%s %02d", months[month], dayMonth);
epaper.drawString(buffer2, epaper.width() / 2, 270);
epaper.update();
}
}
else {
Serial.println("Failed to obtain time");
return;
}
}Let’s break down how it works:
At the top, you’ll see the required libraries: TFT_eSPI for controlling the EPD, WiFi for network access, time for RTC handling, and "bitmap.h" for your background image array.
Note: Always make sure you generate your driver.h file with the right definitions for your EPD and driver board:
#define BOARD_SCREEN_COMBO 503 // 5.83 inch monochrome ePaper Screen (UC8179)
#define USE_XIAO_EPAPER_DISPLAY_BOARD_EE04Before setup(), a few key variables are defined:
Your WiFi SSID and password:
const char* ssid = "yourSSID";
const char* password = "yourPassword";An array with the names of the months, so you can show “January” instead of just “01”:
const char* months [] = {
"January", "February", ..., "December"
};Two buffers for building the strings to display:
char buffer1[6]; // For "HH:MM"
char buffer2[15]; // For "Month DD"And finally, the variable lastMinute is defined and set to -1. We’ll use this later to detect when it’s time to refresh the display.
Inside setup(), you initialize Serial for debugging, connect to WiFi, sync the RTC using NTP, and prepare the EPD:
// Initialize the EPD and configure it
epaper.begin();
epaper.fillScreen(TFT_WHITE); // Clear the screen
epaper.setFreeFont(&Orbitron_Light_32); // Select font
epaper.setRotation(0);
epaper.setTextDatum(MC_DATUM); // Center textDuring the EPD initialization, you’ll notice the screen is cleared and a large custom font is selected—the font definition can be found in the library’s Fonts\Custom folder.
The display is also rotated 0 degrees, and the text datum is set for proper text alignment.
The “datum” deserves a bit more explanation: it’s the reference point used to position text on the screen. There are actually twelve possible values you can use, as shown in the following image:
In our code, it’s set to MC_DATUM, which is especially useful for centering text on the screen.
The setup ends with the drawBitmap instruction, which loads the bitmap image (it won’t actually appear on the display until you call update).
// Display background bitmap
epaper.drawBitmap(0, 270, bottom, 648, 211, TFT_BLACK);In the loop, the current time is read from the ESP32’s RTC using getLocalTime, and the current minute value is compared with the lastMinute variable. This check runs continuously to detect when the minute changes, so the display can be updated at the right interval.
When a new minute is detected, the current date and time are printed to the serial port for debugging, and the variables hour, minutes, dayMonth, and month are loaded from the RTC.
Then, lastMinute is updated and the new values are displayed on the EPD.
// Clear previous text
epaper.fillRect(0, 60, epaper.width(), 120, TFT_WHITE);
// Display time with large digits
epaper.setTextSize(4);
sprintf(buffer1, "%02d:%02d", hour, minutes);
epaper.drawString(buffer1, epaper.width() / 2, 100);
// Display date with smaller characters
epaper.setTextSize(2);
// Clear previous text
epaper.fillRect(50, 250, 560, 70, TFT_WHITE);
sprintf(buffer2, "%s %02d", months[month], dayMonth);
epaper.drawString(buffer2, epaper.width() / 2, 270);
epaper.update();Before drawing the time—and again before drawing the date—a white rectangle is drawn in that area of the screen. This clears any previous text, so if the new text is shorter than before, you won’t see any leftover pixels on the sides.
Next, the text size is set with setTextSize(4), making the time appear in big, easy-to-read characters.
A string is then created using sprintf to format the time as "HH:MM", and it’s displayed horizontally centered on the EPD. The same process is repeated for the date: a slightly smaller text size is chosen, the old date is erased, and a string with the month name and day is shown.
Finally, update is called to send everything to the display.
Here’s the final result, including a small 3D-printed stand.
The program we looked at earlier updates the display every minute using the update method, which, as we’ve seen, performs a full refresh and updates every pixel on the screen.
Even though monochrome EPDs are fast, this kind of refresh causes a noticeable flicker that can be a bit annoying.
To avoid this, we’ll use partial refresh. As we discussed earlier in this tutorial, partial refresh means only updating a specific region of the display, leaving the rest unchanged.
We’ll apply partial refresh just to the time (hours and minutes), since that’s the part that changes most frequently, while the date will still be updated with a full refresh—something that, as you’ll see, is still necessary.
The Seeed_GFX function that handles partial refresh is called updataPartial (yes, there’s a typo in the definition—it should be updatePartial).
You use it like this:
updataPartial(x, y, w, h)With this function, you can refresh a rectangular area (or “window”) of the display, where the top-left corner is at coordinates x, y, and the area is w pixels wide and h pixels tall.
Let’s take a look at a first version that includes this function:
// Clear previous text
epaper.fillRect(0, 60, epaper.width(), 120, TFT_WHITE);
// Display time with large digits
epaper.setTextSize(4);
sprintf(buffer1, "%02d:%02d", hour, minutes);
epaper.drawString(buffer1, epaper.width() / 2, 100);
epaper.updataPartial (0,60,epaper.width(), 120);I added a partial refresh to update the cleared area and redraw the time (hours and minutes), and removed the original full refresh (this means the date won’t be displayed for now, but we’ll fix that later).
With this change, after running for about ten minutes, the clock looks like this:
That’s the ghosting effect appearing right away.
The library probably needs some tweaking to reduce how noticeable the ghosting is, but in the meantime, we can try a simple trick to make it less obvious.
One effective method is to draw the same image in white before updating it with the new value. In our case, that means displaying the same time but using white text first.
Something like this:
epaper.setTextSize(4);
sprintf(buffer1, "%02d:%02d", hour, lastMinute);
epaper.drawString(buffer1, epaper.width() / 2, 100, TFT_WHITE);
epaper.updataPartial (0,60,epaper.width(), 120);
lastMinute = minutes;
// Clear previous text
epaper.fillRect(0, 60, epaper.width(), 120, TFT_WHITE);
// Display time with large digits
sprintf(buffer1, "%02d:%02d", hour, minutes);
epaper.drawString(buffer1, epaper.width() / 2, 100);
epaper.updataPartial (0,60,epaper.width(), 120);With this change, the previous time values are first drawn in white (TFT_WHITE), then the rectangle is cleared, and the updated value is printed in black.
This trick significantly improves the display quality.
After about 10 minutes, the screen now looks like this:
You can see there’s a slight rectangular shadow in the background, but it’s barely noticeable.
However, you shouldn’t use partial refresh all the time—constantly updating the EPD this way can cause long-term damage. It’s best to perform a full refresh periodically.
To do this, you can add a counter so that after a set number of partial refreshes (for example, every 30), a full refresh is triggered.
With that adjustment, the final clock code looks like this:
#include "TFT_eSPI.h"
#include "bitmap.h"
EPaper epaper;
#include <WiFi.h>
#include <time.h>
const char* ssid = "yourSSID";
const char* password = "yourPassword";
const int MAX_PARTIAL_UPDATES = 30;
const char* months [] = {
"January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"
};
char buffer1[6]; // "HH:MM" + null terminator
char buffer2[15]; // Month name and day of month
int lastMinute = -1;
int timeToUpdate = 1;
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!");
// Time zone configuration
configTime(-3 * 3600, 0, "pool.ntp.org");
// Initialize the EPD and configure it
epaper.begin();
epaper.fillScreen(TFT_WHITE); // Clear the screen
epaper.setFreeFont(&Orbitron_Light_32); // Select font
epaper.setRotation(0);
epaper.setTextDatum(MC_DATUM); // Center text
// Display background bitmap
epaper.drawBitmap(0, 270, bottom, 648, 211, TFT_BLACK);
epaper.update ();
}
void loop() {
struct tm timeinfo;
if (getLocalTime(&timeinfo)) {
if (timeinfo.tm_min != lastMinute) {
Serial.println("Update hour");
Serial.println(&timeinfo, "Time: %H:%M:%S");
Serial.println(&timeinfo, "Date: %Y-%m-%d");
int hour = timeinfo.tm_hour;
int minutes = timeinfo.tm_min;
int dayMonth = timeinfo.tm_mday;
int month = timeinfo.tm_mon;
// Print the same value but in white color
sprintf(buffer1, "%02d:%02d", hour, lastMinute);
epaper.drawString(buffer1, epaper.width() / 2, 100, TFT_WHITE);
epaper.updataPartial (0,60, epaper.width(), 120);
// Update
lastMinute = minutes;
// Clear previous text
epaper.fillRect(0, 60, epaper.width(), 120, TFT_WHITE);
epaper.updataPartial (0,60, epaper.width(), 120);
// Display time with large digits
epaper.setTextSize(4);
sprintf(buffer1, "%02d:%02d", hour, minutes);
epaper.drawString(buffer1, epaper.width() / 2, 100);
epaper.updataPartial (0,60, epaper.width(), 120);
// Display date with smaller characters
epaper.setTextSize(2);
// Clear previous text
epaper.fillRect(50, 250, 560, 70, TFT_WHITE);
sprintf(buffer2, "%s %02d", months[month], dayMonth);
epaper.drawString(buffer2, epaper.width() / 2, 270);
timeToUpdate -=1; // Decrement counter of partial updates
if (timeToUpdate == 0) {
Serial.println ("Full update EPD");
epaper.update (); // Full update
timeToUpdate = MAX_PARTIAL_UPDATES; // Reload counter
}
}
}
else {
Serial.println("Failed to obtain time");
return;
}
}The timeToUpdate counter is decreased every minute, whenever the time is updated.
If it reaches zero, a full refresh is performed and the counter is reset to MAX_PARTIAL_UPDATES (which you can set to 30).
Initially, timeToUpdate is set to 1 to force a full update and display the date the first time.
ConclusionsIn this project, we designed an Internet-synced clock and calendar. Along the way, we learned what NTP is, how an NTP server works, and how you can use it to sync the ESP32’s internal RTC with universal time.
We also reviewed several Seeed_GFX functions for displaying text, and saw how to draw monochrome bitmaps on the screen. Finally, we explored the EPD’s partial refresh capability, looked at how it’s implemented in the library, and picked up a few practical tricks to make the most of it.
From here, you can take the project further—add new features (maybe by using the EE04’s buttons), or use everything you’ve learned as the foundation for a brand new project.
Let your imagination run wild!









Comments