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.

/*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() {
}
