In this tutorial we will build an Analog clock using an 4.2″ e-Paper display and an ESP32. The clock will synchronize its time with an internet time provider (SNTP server). Furthermore the ESP32 and display will sleep between display updates to increase the running time of the clock on battery power.
Let’s start with the required parts.
Required Parts
I am using the ES32 lite as a microprocessor, since it is cheap and has a battery charging interface, which allows you to run the clock on a LiPo battery. Any other ESP32 or ESP8266 with sufficient memory will work as well but preferably get one with a battery charging interface.
4.2″ e-Paper Display
ESP32 lite
USB Data Cable
Dupont Wire Set
Breadboard
Makerguides.com is a participant in the Amazon Services LLC Associates Program, an affiliate advertising program designed to provide a means for sites to earn advertising fees by advertising and linking to products on Amazon.com. As an Amazon Associate we earn from qualifying purchases.
E-paper Display
The e-Paper display used in this project is a 4.2″ display module, with 400×300 pixels resolution, 4 grey levels, a partial refresh time of 0.4 seconds, and an embedded controller with an SPI interface.
Note that the module has a little jumper pad/switch at the back to switch from 4-wire SPI to 3-wire SPI. We are going to use the default 4-wire SPI here. So you should not have to change anything.
The display module runs on 3.3V or 5V, has a very low sleep current of 0.01µA and consumes only about 26.4mW while refreshing. For more information on e-Paper displays, in general, have a look at the following two tutorials: Interfacing Arduino To An E-ink Display and Weather Station on e-Paper Display.
Connecting and Testing the e-Paper
First, let us connect and test the function of the e-Paper. The following picture shows the complete wiring for power and SPI.
Below a table with all the connections for convenience. Note that you can power the display with 3.3V or 5V but the ESP32-lite has only a 3.3V output and the SPI data lines must be 3.3V!
e-Paper display | ESP32 lite |
---|---|
CS/SS | 5 |
SCL/SCK | 18 |
SDA/DIN/MOSI | 23 |
BUSY | 15 |
RES/RST | 2 |
DC | 0 |
VCC | 3.3V |
GND | G |
You can use a breadboard to wire everything up. But I actually connected the display directly to the ESP32 with Dupont wires and also added a LiPo battery to test running the clock on battery power. The picture below shows how my setup looked like.
Install GxEPD2 library
Before we can draw or write on the e-Paper display we need to install two libraries. The Adafruit_GFX graphics library, which provides a common set of graphics primitives (text, points, lines, circles, etc.). And the GxEPD2 library, which provides the graphics driver software for the E-Paper Display.
Just install the libraries the usual way. After the installation they should appear in the Library Manager as follows.
Test code
Once you have installed the library run the following test code to ensure that everything works.
#include "GxEPD2_BW.h" //CS(SS)=5, SCL(SCK)=18, SDA(MOSI)=23, BUSY=15, RES(RST)=2, DC=0 GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15)); void setup() { epd.init(115200, true, 50, false); epd.setRotation(1); epd.setTextColor(GxEPD_BLACK); epd.setTextSize(2); epd.setFullWindow(); epd.fillScreen(GxEPD_WHITE); epd.setCursor(90, 190); epd.print("Makerguides"); epd.display(); epd.hibernate(); } void loop() {}
The text code prints the text “Makerguides” and after some flickering of the display (full refresh) you should see it on your display as shown below:
If not, then something is wrong. Most likely either the display is not correctly wired or the wrong display driver is chosen. The critical line of code is this one, where we specify the 4.2″ display driver:
GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
The Readme for GxEPD2 library lists all the supported displays and you can find the specifics in the header files, e.g. GxEPD2.h. Find the display driver specific for your display. This may take some trial and error.
Code for an Analog Clock on e-Paper
The following code is the is the complete code for an analog clock that automatically synchronizes its time with an internet time server (SNTP) and displays time and date on a 4.2″ e-Paper display. Between content refreshes the display and the ESP32 are in sleep mode to preserve battery power. Have a quick look at the code first and then we will dive into the details.
#include "GxEPD2_BW.h" #include "Fonts/FreeSans9pt7b.h" #include "Fonts/FreeSansBold9pt7b.h" #include "WiFi.h" #include "esp_sntp.h" const char* TIMEZONE = "AEST-10AEDT,M10.1.0,M4.1.0/3"; const char* SSID = "SSID"; const char* PWD = "PASSWORD"; const char* DAYSTR[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }; // W, H flipped due to setRotation(1) const int H = GxEPD2_420_GDEY042T81::WIDTH; const int W = GxEPD2_420_GDEY042T81::HEIGHT; const int CW = W / 2; const int CH = H / 2; const int R = min(W, H) / 2; const uint16_t WHITE = GxEPD_WHITE; const uint16_t BLACK = GxEPD_BLACK; RTC_DATA_ATTR uint16_t wakeups = 0; GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15)); void initDisplay() { bool initial = wakeups == 0; epd.init(115200, initial, 50, false); epd.setRotation(1); epd.setTextSize(1); epd.setTextColor(BLACK); } void setTimezone() { setenv("TZ", TIMEZONE, 1); tzset(); } void syncTime() { if (wakeups % 50 == 0) { WiFi.begin(SSID, PWD); while (WiFi.status() != WL_CONNECTED) ; configTzTime(TIMEZONE, "pool.ntp.org"); } } void printAt(int16_t x, int16_t y, const char* text) { int16_t x1, y1; uint16_t w, h; epd.getTextBounds(text, x, y, &x1, &y1, &w, &h); epd.setCursor(x - w / 2, y + h / 2); epd.print(text); } void printfAt(int16_t x, int16_t y, const char* format, ...) { static char buff[64]; va_list args; va_start(args, format); vsnprintf(buff, 64, format, args); printAt(x, y, buff); } void polar2cart(float x, float y, float r, float alpha, int& cx, int& cy) { alpha = alpha * TWO_PI / 360; cx = int(x + r * sin(alpha)); cy = int(y - r * cos(alpha)); } void drawClockFace() { int cx, cy; epd.setFont(&FreeSansBold9pt7b); epd.drawCircle(CW, CH, R - 2, BLACK); epd.fillCircle(CW, CH, 8, BLACK); for (int h = 1; h <= 12; h++) { float alpha = 360.0 * h / 12; polar2cart(CW, CH, R - 20, alpha, cx, cy); printfAt(cx, cy, "%d", h); polar2cart(CW, CH, R - 40, alpha, cx, cy); epd.fillCircle(cx, cy, 3, BLACK); } for (int m = 1; m <= 12 * 5; m++) { float alpha = 360.0 * m / (12 * 5); polar2cart(CW, CH, R - 40, alpha, cx, cy); epd.fillCircle(cx, cy, 2, BLACK); } } void drawTriangle(float alpha, int width, int len) { int x0, y0, x1, y1, x2, y2; polar2cart(CW, CH, len, alpha, x2, y2); polar2cart(CW, CH, width, alpha - 90, x1, y1); polar2cart(CW, CH, width, alpha + 90, x0, y0); epd.drawTriangle(x0, y0, x1, y1, x2, y2, BLACK); } void drawClockHands() { struct tm t; getLocalTime(&t); float alphaM = 360 * (t.tm_min / 60.0); float alphaH = 30 * (t.tm_hour % 12); drawTriangle(alphaM, 8, R - 50); drawTriangle(alphaH, 8, R - 65); } void drawDateDay() { struct tm t; getLocalTime(&t); epd.setFont(&FreeSans9pt7b); printfAt(CW, CH+R/3, "%02d-%02d-%02d", t.tm_mday, t.tm_mon + 1, t.tm_year -100); printfAt(CW, CH-R/3, "%s", DAYSTR[t.tm_wday]); } void drawClock(const void* pv) { if (wakeups % 120 == 0) { epd.setFullWindow(); } else { epd.setPartialWindow(0, 0, W, H); } epd.fillScreen(WHITE); drawClockFace(); drawClockHands(); drawDateDay(); } void setup() { initDisplay(); setTimezone(); syncTime(); epd.drawPaged(drawClock, 0); epd.hibernate(); wakeups = (wakeups + 1) % 1000; esp_sleep_enable_timer_wakeup(30 * 1000 * 1000); esp_deep_sleep_start(); } void loop() { }
If you upload the code to your ESP32, you should see the following clock displayed:
It show the clock face, the two clock hands, and the current day and date.
Libraries
We start by including the GxEPD2_BW.h
header file for the black and white (BW) e-Paper display. If you have a 3-color display you would include GxEPD2_3C.h
, or GxEPD2_4C.h
for a 4-color display, and GxEPD2_7C.h
for a 7-color display, instead.
#include "GxEPD2_BW.h" #include "Fonts/FreeSans9pt7b.h" #include "Fonts/FreeSansBold9pt7b.h"
We also include two files for the fonts used to display the clock labels and date. The clock labels are in a bold, font (FreeSansBold9pt7b
) and the date is in a thinner font (FreeSans9pt7b
) – see the clock picture above. You can find an overview of the AdaFruit GFX fonts here, in case you want to change fonts.
Finally, we include the WiFi.h
and the esp_snt.h
libraries, which we will need to synchronize the clock with an SNTP internet time server over Wi-Fi.
#include "WiFi.h" #include "esp_sntp.h"
Constants
Next we define some constants. Most importantly, you will have to define the SSID
and PASSWORD
for your WiFi, and the TIMEZONE
you are living in.
const char* SSID = "SSID"; const char* PWD = "PASSWORD"; const char* TIMEZONE = "AEST-10AEDT,M10.1.0,M4.1.0/3";
The above time zone specification “AEST-10AEDT,M10.1.0,M4.1.0/3
” is for Australia, which corresponds to the Australian Eastern Standard Time (AEST) with daylight saving time adjustments.
The parts of this time zone definition are as follows
- AEST: Australian Eastern Standard Time
- -10: UTC offset of 10 hours ahead of Coordinated Universal Time (UTC)
- AEDT: Australian Eastern Daylight Time
- M10.1.0: Transition to daylight saving time occurs on the 1st Sunday of October
- M4.1.0/3: Transition back to standard time occurs on the 1st Sunday of April, with a 3-hour difference from UTC.
For other time zone definitions have a look at the Posix Timezones Database. Just copy and paste the string you find there and change the TIMEZONE
constant accordingly.
In addition to the date, we also want to show the name of the current day, e.g. Thursday. The DAYSTR
constant defines the day names. Feel free to change the language, or pick shorter names but make sure to maintain the order.
const char* DAYSTR[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" };
Next, we re-define constants for the width W
and height H
of the display. This is mostly for convenience. Note that width and hight are swapped, since we rotate the display (setRotation(1)
) in the initDisplay
function.
const int H = GxEPD2_420_GDEY042T81::WIDTH; const int W = GxEPD2_420_GDEY042T81::HEIGHT;
Finally, we define constants for the center position (CW
, CH
) on the display, the maximum radius R
of a circle on the display, and shorter names for the black and white colors.
const int CW = W / 2; const int CH = H / 2; const int R = min(W, H) / 2; const uint16_t WHITE = GxEPD_WHITE; const uint16_t BLACK = GxEPD_BLACK;
Variables and Objects
After the constants, we define a global variable to count the wakeups and the display object.
RTC_DATA_ATTR uint16_t wakeups = 0; GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
The wakeups
variable gets incremented whenever the ESP32 wakes up from deep-sleep. It is stored in RTC memory so that it preserves its value even when the ESP32 goes into deep-sleep mode. We use it to decide when to fully or partially refresh the display, and how frequently to synchronize the time.
The epd
object represents the display (e–paper display). This object definition must match the e-Paper display type you have. Here we have 4.2 inch (= _420) e-Display. See the Readme for GxEPD2 library and the GxEPD2.h file for the supported displays.
initDisplay Function
The initDisplay
function initializes the display, set the display orientation to landscape, the text size to 1 and the text color to black.
void initDisplay() { bool initial = wakeups == 0; epd.init(115200, initial, 50, false); epd.setRotation(1); epd.setTextSize(1); epd.setTextColor(BLACK); }
epd.init()
usually causes a full refresh of the display when executed. However, we want a full refresh only after resetting the ESP32 and a partial refresh when waking up from deep sleep. To achieve this we set the initial
parameter epd.init()
function to true when wakeups == 0
, which is after a reset and otherwise initial
is false, for a partial refresh.
setTimezone Function
The setTimezone
function sets the TIMEZONE
. We have to call it every time in the setup function, since after deep-sleep or a reset the time zone information is lost.
void setTimezone() { setenv("TZ", TIMEZONE, 1); tzset(); }
syncTime Function
The syncTime
function creates a WiFi connection and then synchronizes the internal clock of the ESP32 with the SNTP server “pool.ntp.org
” by calling configTzTime()
.
You can specify other, or even multiple SNTP servers to connect to. Have a look at the How to synchronize ESP32 clock with SNTP server tutorial for more information.
void syncTime() { if (wakeups % 50 == 0) { WiFi.begin(SSID, PWD); while (WiFi.status() != WL_CONNECTED) ; configTzTime(TIMEZONE, "pool.ntp.org"); } }
However, we want to synchronize the time only occasionally. Since the ESP32 is set to sleep for 30 seconds (see loop
function), the expression wakeups % 50 == 0
, ensures that the time is synchronized only every 25 minutes (30sec * 50 / 60sec).
You can change this but be aware that more frequent synchronizations will consume more battery power (Wi-Fi is power hungry). Less frequent synchronizations, on the other hand, means that the clock does react slower when switching to daylight savings time.
printAt functions
The printAt
functions displays centered text at the specified screen coordinates.
void printAt(int16_t x, int16_t y, const char* text) { int16_t x1, y1; uint16_t w, h; epd.getTextBounds(text, x, y, &x1, &y1, &w, &h); epd.setCursor(x - w / 2, y + h / 2); epd.print(text); } void printfAt(int16_t x, int16_t y, const char* format, ...) { static char buff[64]; va_list args; va_start(args, format); vsnprintf(buff, 64, format, args); printAt(x, y, buff); }
While printAt()
only prints plain text, the printfAt()
function works like printf and allows you to provide a format specifier, e.g. printfAt(x, y, "%02d-%02d-%02d", m, h, y);
polar2cart function
The polar2cart function converts polar coordinates that are given by a radius r
and an angle alpha
into cartesian coordinates cx,
cy
.
void polar2cart(float x, float y, float r, float alpha, int& cx, int& cy) { alpha = alpha * TWO_PI / 360; cx = int(x + r * sin(alpha)); cy = int(y - r * cos(alpha)); }
This function comes in very handy, since time data such 8:15 o’clock are essentially polar coordinates on the clock and we have to convert them to the cartesian coordinate system the display is using.
drawClockFace function
The drawClockFace()
function, as the name suggests, draws the face of the clock. This includes the frame, the minute ticks and the hour labels and ticks. As you can see, it makes good use of the polar2cart()
function we discussed before.
void drawClockFace() { int cx, cy; epd.setFont(&FreeSansBold9pt7b); // Frame epd.drawCircle(CW, CH, R - 2, BLACK); epd.fillCircle(CW, CH, 8, BLACK); // Hour ticks and labels for (int h = 1; h <= 12; h++) { float alpha = 360.0 * h / 12; polar2cart(CW, CH, R - 20, alpha, cx, cy); printfAt(cx, cy, "%d", h); polar2cart(CW, CH, R - 40, alpha, cx, cy); epd.fillCircle(cx, cy, 3, BLACK); } // Minute ticks for (int m = 1; m <= 12 * 5; m++) { float alpha = 360.0 * m / (12 * 5); polar2cart(CW, CH, R - 40, alpha, cx, cy); epd.fillCircle(cx, cy, 2, BLACK); } }
drawTriangle function
The drawTriangle()
function draws a triangle but in polar coordinates. The base of the triangle is at the center of the clock and the triangle is angled with angle alpha
. width
is the width of the base and len
is the length of the triangle. This function is used to draw the clock hands.
void drawTriangle(float alpha, int width, int len) { int x0, y0, x1, y1, x2, y2; polar2cart(CW, CH, len, alpha, x2, y2); polar2cart(CW, CH, width, alpha - 90, x1, y1); polar2cart(CW, CH, width, alpha + 90, x0, y0); epd.drawTriangle(x0, y0, x1, y1, x2, y2, BLACK); }
You can change from epd.drawTriangle()
to epd.fillTriangle()
, if you prefer filled clock hands. They are easier to see but will obstruct the current day and date display.
drawClockHands function
The actual drawing of the clock hands is performed by the drawClockHands()
function. It gets the current local time, converts minutes and hours into angles and then uses drawTriangle()
to draw the clock hands.
void drawClockHands() { struct tm t; getLocalTime(&t); float alphaM = 360 * (t.tm_min / 60.0); float alphaH = 30 * (t.tm_hour % 12); drawTriangle(alphaM, 8, R - 50); drawTriangle(alphaH, 8, R - 65); }
drawDateDay function
In addition to the time shown by the clock hands, I also wanted to show the current date and the day of the week. The drawDateDay()
function gets the current local time and date and then print the day name, e.g. Thurday at the top of the clock face and the date, e.g. 05-09-24 at the bottom. You can easily change the format specifier to change the date format for your country.
void drawDateDay() { struct tm t; getLocalTime(&t); epd.setFont(&FreeSans9pt7b); printfAt(CW, CH+R/3, "%02d-%02d-%02d", t.tm_mday, t.tm_mon + 1, t.tm_year -100); printfAt(CW, CH-R/3, "%s", DAYSTR[t.tm_wday]); }
drawClock function
The drawClock()
function puts it all together. It clears the screen and then draws the clock face, the clock hands and the date.
void drawClock(const void* pv) { if (wakeups % 120 == 0) { epd.setFullWindow(); } else { epd.setPartialWindow(0, 0, W, H); } epd.fillScreen(WHITE); drawClockFace(); drawClockHands(); drawDateDay(); }
Depending on the wakeups
counter either a full or partial refresh of the display is performed. A full refresh is slow (2-4 seconds) and causes a lot of flickering of the display. A partial refresh is much faster (<0.5 sec) and without flickering.
However, a partial refresh leaves a faint after-image of the old content. If you look closely at the picture below you can see the after-images of the minute hand left, while it moved from 5 to 10.
To remove the after-images, we occasionally want to perform a full refresh of the display. In the drawClock()
function we do this when the wakeup
counter reaches 120: wakeups % 120 == 0
. Since the deep-sleep time is set to 30 seconds, this means a full refresh every 30sec*120/60sec = 60mins.
setup and loop function
Finally, we have the usual setup
and loop
functions. The loop function is empty, since the ESP32 goes to deep-sleep at the end of the setup
function and the loop
function is therefore never executed.
void setup() { initDisplay(); setTimezone(); syncTime(); epd.drawPaged(drawClock, 0); epd.hibernate(); wakeups = (wakeups + 1) % 1000; esp_sleep_enable_timer_wakeup(30 * 1000 * 1000); esp_deep_sleep_start(); } void loop() { }
The setup function starts by initializing the display, setting the timezone and (maybe) synchronizing the time (depending on the wakeup counter).
Then we call drawPaged
to execute the drawClock
functions and afterwards hibernate the display to save energy. If you need more details have a look at the Partial Refresh of e-Paper Display tutorial, where we explain the drawPaged
function in more detail.
Before we put the ESP32 for 30 seconds (30 * 1000 * 1000 microseconds) into deep-sleep, we increment the wakeup
counter and compute the modulo 1000 to avoid an overflow of the counter.
And that’s it! With this code you have a nice analog clock that is always accurate and can run on battery power.
Conclusions
In this tutorial you learned how to build an analog clock on a 4.2″e-Paper display that synchronizes its time with an SNTP sever and always shows accurate time and date. The clock utilizes the deep-sleep capability of the ESP32 to reduce power consumption and increase running time on a battery.
If you use the suggested ESP32 LOLIN Lite, connecting a rechargeable LiPo battery is simple and you could use a MAX1704X to monitor charging levels.
Finally, if you prefer a digital clock over an analog clock have a look at our Digital Clock on e-Paper Display tutorial.
Happy Tinkering ; )
Stefan is a professional software developer and researcher. He has worked in robotics, bioinformatics, image/audio processing and education at Siemens, IBM and Google. He specializes in AI and machine learning and has a keen interest in DIY projects involving Arduino and 3D printing.