Building an E-Paper Analog Clock with ESP32 - Full Tutorial
2025-05-19 | By Mirko Pavleski
License: General Public License Displays Real Time Clocks (RTCs) Arduino
In several of my previous projects you could see various unusual clocks, including several in the retro analog style. This time I will present you another clock from this group, but now on a E-paper Display. Specifically, in this project, I used the CrowPanel ESP32 4.2” E-paper Display module with built-in ESP32S3 MCU.
I have this display from a previous project of mine and I can tell you that it is very practical in the sense that there is no need to connect components and solder, and it has multiple IO ports, a microSD slot, multiple buttons, and even a battery charger circuit. I got the idea for this project from the Makerguides website, so I made several changes and additions to the basic code.
The changes consist of:
- adjusting the code specifically for the above-mentioned display module
- Changing the orientation from vertical to horizontal
- correcting residual "ghost" prints as a result of partial refresh
- Every 60 seconds (the elapsed minute), a full refresh of the screen, during which the colors are briefly inverted, which presents a nice visual and informative effect
- Unlike the original code, the hour hand now moves continuously and proportionally to the elapsed minutes
- and the outer frame of the clock is thickened, and its parameters can be changed in the code
Of course, I have added several new options that, in addition to the visual, also have a very useful informative character, and I will explain their functioning in the description of the clock's operation.
New Functions:
- Two progress bars for graphical display of elapsed time, each of them divided into four intervals,
- Digital information about the elapsed hours of the current day, as well as the minutes of the current hour
- Change clock face with a button between Arabic and Roman numerals.
- and also, with the press of a button, there is an option to invert colors.
This project is sponsored by PCBWay. They have all the services you need to create your project, whether is a school project or a complex professional project. On PCBWay you can share your experiences or get inspiration for your next project. They also provide completed Surface mount SMT PCB assembly service with ISO9001 quality control.
As for the code, as you can see, it is designed in a way that allows you to easily change the basic graphic parameters, so you can easily create a custom-looking clock face according to your own ideas.
It is important to mention that the exact time is downloaded via the Internet according to the time zone in which you live. For other time zone definitions, have a look at the Posix Timezones Database. You also need to enter the credentials from your local Wi-Fi network.
Now let's see how the device works in real conditions. After switching on, a certain amount of time should pass while the clock connects to Wi-Fi and downloads the correct time. Then the clock appears in an analog style, drawn on a white background. It shows the correct time, the day of the week, as well as a full date in the format day/month/year.
On both sides of the clock there are two progress bars. The right one shows the elapsed time of the current day in graphical form, and the lower part shows the numerical value of this information. Similarly, the left progress bar shows the elapsed time of the current hour, also in graphical and numerical form. For a better visual representation of the elapsed time, the two progress bars are divided into four parts, with one part on the right bar representing 6 hours and on the left bar, 15 minutes.
As I mentioned earlier, the display module contains several buttons, so I used two of them for additional options. By pressing the upper button, the numbers that indicate the hours are transformed from Arabic to Roman.
By pressing the button again, they return to their original state. Now, by pressing the lower button, the colors of the display are inverted so that the background is black, and the hour is white.
During the explanation, you could notice that the screen refreshes exactly at the moment when a new minute begins, which represents an additional visual and informative effect. Considering that the display refreshes very briefly, once a minute, the battery lasts a very long time.
And finally, a short conclusion. This is a A low-power e-paper Analog style clock with smart features like Wi-Fi time sync, invertible display, Roman/Arabic numeral toggling, real-time progress bars, and minute-by-minute updates, built on an ESP32 Display module for plug-and-play simplicity.
/*E-Paper Analog Clock with ESP32 by mircemk, May 2025 */ #include "GxEPD2_BW.h" #include "Fonts/FreeSans9pt7b.h" #include "Fonts/FreeSansBold9pt7b.h" #include "WiFi.h" #include "esp_sntp.h" const char* TIMEZONE = "CET-1CEST,M3.5.0,M10.5.0/3"; const char* SSID = "******"; const char* PWD = "******"; // Pin definitions #define PWR 7 #define BUSY 48 #define RES 47 #define DC 46 #define CS 45 #define BUTTON_PIN 2 #define INVERT_BUTTON_PIN 1 // IO1 for inversion RTC_DATA_ATTR bool useRomanNumerals = false; // Store number style state in RTC memory RTC_DATA_ATTR bool invertedDisplay = false; // Store display inversion state // Helper function to convert number to Roman numeral const char* toRoman(int number) { static char roman[10]; const char* romanNumerals[] = {"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"}; if (number >= 1 && number <= 12) { strcpy(roman, romanNumerals[number - 1]); return roman; } return ""; } const char* DAYSTR[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }; // W, H flipped due to setRotation(1) const int H = GxEPD2_420_GDEY042T81::HEIGHT; // Note: Using HEIGHT first const int W = GxEPD2_420_GDEY042T81::WIDTH; // Using WIDTH second const int CW = W / 2; // Center Width const int CH = H / 2; // Center Height const int R = min(W, H) / 2 - 10; // Radius with some margin const int BAR_WIDTH = 20; const int BAR_HEIGHT = GxEPD2_420_GDEY042T81::HEIGHT/1.3; // Half of display height const int BAR_MARGIN = 25; // Distance from clock edge const uint16_t WHITE = GxEPD_WHITE; const uint16_t BLACK = GxEPD_BLACK; RTC_DATA_ATTR uint16_t wakeups = 0; GxEPD2_BW<GxEPD2_420_GYE042A87, GxEPD2_420_GYE042A87::HEIGHT> epd(GxEPD2_420_GYE042A87(CS, DC, RES, BUSY)); uint16_t getFgColor() { return invertedDisplay ? WHITE : BLACK; } uint16_t getBgColor() { return invertedDisplay ? BLACK : WHITE; } void drawDisplayFrame() { // Outer frame epd.drawRect(0, 0, W, H, getFgColor()); // Inner frame (3 pixels gap) epd.drawRect(4, 4, W-8, H-8, getFgColor()); } void epdPower(int state) { pinMode(PWR, OUTPUT); digitalWrite(PWR, state); } void initDisplay() { bool initial = wakeups == 0; epd.init(115200, initial, 50, false); epd.setRotation(0); // Set rotation to 0 (90 degrees) epd.setTextSize(1); epd.setTextColor(getFgColor()); } 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 checkButton() { pinMode(BUTTON_PIN, INPUT_PULLUP); if (digitalRead(BUTTON_PIN) == LOW) { delay(50); // Debounce if (digitalRead(BUTTON_PIN) == LOW) { useRomanNumerals = !useRomanNumerals; redrawDisplay(); while(digitalRead(BUTTON_PIN) == LOW); // Wait for button release } } } void checkInversionButton() { pinMode(INVERT_BUTTON_PIN, INPUT_PULLUP); if (digitalRead(INVERT_BUTTON_PIN) == LOW) { delay(50); // Debounce if (digitalRead(INVERT_BUTTON_PIN) == LOW) { invertedDisplay = !invertedDisplay; redrawDisplay(); while(digitalRead(INVERT_BUTTON_PIN) == LOW); // Wait for button release } } } void redrawDisplay() { epd.setFullWindow(); epd.fillScreen(getBgColor()); drawDisplayFrame(); drawProgressBars(); drawClockFace(); drawClockHands(); drawDateDay(); epd.display(false); } void drawClockFace() { int cx, cy; epd.setFont(&FreeSansBold9pt7b); epd.setTextColor(getFgColor()); const int FRAME_THICKNESS = 1; // Outer frame thickness const int FRAME_GAP = 3; // Gap between outer and inner circles // Draw outer thick frame for(int i = 0; i < FRAME_THICKNESS; i++) { epd.drawCircle(CW, CH, R + i, getFgColor()); } // Draw inner circle after the gap epd.drawCircle(CW, CH, R - FRAME_GAP, getFgColor()); // Center dot epd.fillCircle(CW, CH, 8, getFgColor()); // Draw hour markers and numbers for (int h = 1; h <= 12; h++) { float alpha = 360.0 * h / 12; // Move numbers slightly inward to accommodate new frame polar2cart(CW, CH, R - 25, alpha, cx, cy); if (useRomanNumerals) { const char* romanNumeral = toRoman(h); printfAt(cx, cy, "%s", romanNumeral); } else { printfAt(cx, cy, "%d", h); } polar2cart(CW, CH, R - 45, alpha, cx, cy); epd.fillCircle(cx, cy, 3, getFgColor()); // Draw minute markers for (int m = 1; m <= 12 * 5; m++) { float alpha = 360.0 * m / (12 * 5); polar2cart(CW, CH, R - 45, alpha, cx, cy); epd.fillCircle(cx, cy, 2, getFgColor()); } } } 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, getFgColor()); } void drawClockHands() { struct tm t; getLocalTime(&t); // Calculate minute angle float alphaM = 360.0 * (t.tm_min / 60.0); // Calculate hour angle with smooth movement float hourAngle = (t.tm_hour % 12) * 30.0; float minuteContribution = (t.tm_min / 60.0) * 30.0; float alphaH = hourAngle + minuteContribution; // Draw the hands drawTriangle(alphaM, 8, R - 50); // Minute hand drawTriangle(alphaH, 8, R - 65); // Hour hand epd.fillCircle(CW, CH, 8, getFgColor()); // Center dot } void drawDateDay() { struct tm t; getLocalTime(&t); epd.setFont(&FreeSans9pt7b); epd.setTextColor(getFgColor()); 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 drawProgressBar(int x, int y, int width, int height, float percentage, const char* label) { // Draw outer rectangle epd.drawRect(x, y, width, height, getFgColor()); // Calculate inner area with margin int innerX = x + 3; int innerY = y + 3; int innerWidth = width - 6; int innerHeight = height - 6; // Calculate fill height int fillHeight = (int)(innerHeight * percentage); int fillTop = innerY + innerHeight - fillHeight; // First draw the filled portion epd.fillRect(innerX, fillTop, innerWidth, fillHeight, getFgColor()); // Now draw the ticks - they'll appear correctly in both filled and empty areas for(int i = 1; i < 4; i++) { int tickY = innerY + (innerHeight * i / 4); // For each pixel in the tick line for(int px = innerX; px < innerX + innerWidth; px++) { // If this pixel is in the filled area, use bg color, else use fg color uint16_t color = (tickY >= fillTop) ? getBgColor() : getFgColor(); epd.drawPixel(px, tickY, color); } } // Draw label above the bar epd.setFont(&FreeSans9pt7b); epd.setTextColor(getFgColor()); int16_t x1, y1; uint16_t w, h; epd.getTextBounds(label, 0, 0, &x1, &y1, &w, &h); epd.setCursor(x + (width - w)/2, y - 10); epd.print(label); } void drawProgressBars() { struct tm t; getLocalTime(&t); float hourProgress = (t.tm_min * 60.0f + t.tm_sec) / (60.0f * 60.0f); float dayProgress = (t.tm_hour * 3600.0f + t.tm_min * 60.0f + t.tm_sec) / (24.0f * 3600.0f); int leftX = BAR_MARGIN; int leftY = (H - BAR_HEIGHT)/2; int rightX = W - BAR_MARGIN - BAR_WIDTH; int rightY = (H - BAR_HEIGHT)/2; // Draw the progress bars drawProgressBar(leftX, leftY, BAR_WIDTH, BAR_HEIGHT, hourProgress, "HOUR"); drawProgressBar(rightX, rightY, BAR_WIDTH, BAR_HEIGHT, dayProgress, "DAY"); // Add elapsed time information below the bars epd.setFont(&FreeSans9pt7b); epd.setTextColor(getFgColor()); // Minutes elapsed char minuteStr[10]; sprintf(minuteStr, "%d m", t.tm_min); int16_t x1, y1; uint16_t w, h; epd.getTextBounds(minuteStr, 0, 0, &x1, &y1, &w, &h); epd.setCursor(leftX + (BAR_WIDTH - w)/2, leftY + BAR_HEIGHT + 20); epd.print(minuteStr); // Hours elapsed char hourStr[10]; sprintf(hourStr, "%d h", t.tm_hour); epd.getTextBounds(hourStr, 0, 0, &x1, &y1, &w, &h); epd.setCursor(rightX + (BAR_WIDTH - w)/2, rightY + BAR_HEIGHT + 20); epd.print(hourStr); } void drawClock(const void* pv) { static int lastMinute = -1; struct tm t; getLocalTime(&t); // Full refresh every minute if (lastMinute != t.tm_min || wakeups == 0) { epd.setFullWindow(); epd.fillScreen(getBgColor()); // Draw the display frame first drawDisplayFrame(); // Draw progress bars first (behind clock) drawProgressBars(); // Draw clock elements drawClockFace(); drawClockHands(); drawDateDay(); lastMinute = t.tm_min; } } void setup() { epdPower(HIGH); initDisplay(); setTimezone(); syncTime(); esp_sleep_wakeup_cause_t wakeup_reason = esp_sleep_get_wakeup_cause(); if (wakeup_reason == ESP_SLEEP_WAKEUP_EXT0) { checkButton(); } if (wakeup_reason == ESP_SLEEP_WAKEUP_EXT1) { uint64_t wakeup_pin_mask = esp_sleep_get_ext1_wakeup_status(); if (wakeup_pin_mask & (1ULL << INVERT_BUTTON_PIN)) { checkInversionButton(); } } drawClock(0); wakeups = (wakeups + 1) % 1000; epd.display(false); epd.hibernate(); // Enable wakeup from both buttons esp_sleep_enable_ext0_wakeup((gpio_num_t)BUTTON_PIN, LOW); esp_sleep_enable_ext1_wakeup((1ULL << INVERT_BUTTON_PIN), ESP_EXT1_WAKEUP_ANY_LOW); struct tm t; getLocalTime(&t); uint64_t sleepTime = (60 - t.tm_sec) * 1000000ULL; esp_sleep_enable_timer_wakeup(sleepTime); esp_deep_sleep_start(); } void loop() { }