Building an E-Paper Analog Clock with ESP32 - Full Tutorial

A low-power e-paper clock with Roman/Arabic numeral toggling, real-time progress bars, and minute-by-minute updates, built on an ESP32 module for plug-and-play simplicity.

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 has all the services you need to create your project at the best price, whether is a scool project, or 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 at a best price, and ISO9001 quality control. Visit pcbway.com for more services
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.


CODE
/*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() {
}
License
All Rights
Reserved
licensBg
0