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.
Components and supplies
1
micro switch
1
LiPoly Battery, 3.7v 500 mAh (LP503035)
1
CrowPanel ESP32 4.2” E-paper Display module
Tools and machines
1
Soldering kit
Apps and platforms
1
Arduino IDE
Project description
Code
Code
cpp
...
1/*E-Paper Analog Clock with ESP32 2by mircemk, May 2025 3*/ 4 5#include "GxEPD2_BW.h" 6#include "Fonts/FreeSans9pt7b.h" 7#include "Fonts/FreeSansBold9pt7b.h" 8#include "WiFi.h" 9#include "esp_sntp.h" 10 11const char* TIMEZONE = "CET-1CEST,M3.5.0,M10.5.0/3"; 12const char* SSID = "******"; 13const char* PWD = "******"; 14 15// Pin definitions 16#define PWR 7 17#define BUSY 48 18#define RES 47 19#define DC 46 20#define CS 45 21#define BUTTON_PIN 2 22#define INVERT_BUTTON_PIN 1 // IO1 for inversion 23 24RTC_DATA_ATTR bool useRomanNumerals = false; // Store number style state in RTC memory 25RTC_DATA_ATTR bool invertedDisplay = false; // Store display inversion state 26 27// Helper function to convert number to Roman numeral 28const char* toRoman(int number) { 29 static char roman[10]; 30 const char* romanNumerals[] = {"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"}; 31 if (number >= 1 && number <= 12) { 32 strcpy(roman, romanNumerals[number - 1]); 33 return roman; 34 } 35 return ""; 36} 37 38const char* DAYSTR[] = { 39 "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" 40}; 41 42// W, H flipped due to setRotation(1) 43const int H = GxEPD2_420_GDEY042T81::HEIGHT; // Note: Using HEIGHT first 44const int W = GxEPD2_420_GDEY042T81::WIDTH; // Using WIDTH second 45 46const int CW = W / 2; // Center Width 47const int CH = H / 2; // Center Height 48const int R = min(W, H) / 2 - 10; // Radius with some margin 49 50const int BAR_WIDTH = 20; 51const int BAR_HEIGHT = GxEPD2_420_GDEY042T81::HEIGHT/1.3; // Half of display height 52const int BAR_MARGIN = 25; // Distance from clock edge 53 54const uint16_t WHITE = GxEPD_WHITE; 55const uint16_t BLACK = GxEPD_BLACK; 56 57RTC_DATA_ATTR uint16_t wakeups = 0; 58GxEPD2_BW<GxEPD2_420_GYE042A87, GxEPD2_420_GYE042A87::HEIGHT> epd(GxEPD2_420_GYE042A87(CS, DC, RES, BUSY)); 59 60uint16_t getFgColor() { 61 return invertedDisplay ? WHITE : BLACK; 62} 63 64uint16_t getBgColor() { 65 return invertedDisplay ? BLACK : WHITE; 66} 67 68void drawDisplayFrame() { 69 // Outer frame 70 epd.drawRect(0, 0, W, H, getFgColor()); 71 72 // Inner frame (3 pixels gap) 73 epd.drawRect(4, 4, W-8, H-8, getFgColor()); 74} 75 76void epdPower(int state) { 77 pinMode(PWR, OUTPUT); 78 digitalWrite(PWR, state); 79} 80 81void initDisplay() { 82 bool initial = wakeups == 0; 83 epd.init(115200, initial, 50, false); 84 epd.setRotation(0); // Set rotation to 0 (90 degrees) 85 epd.setTextSize(1); 86 epd.setTextColor(getFgColor()); 87} 88 89void setTimezone() { 90 setenv("TZ", TIMEZONE, 1); 91 tzset(); 92} 93 94void syncTime() { 95 if (wakeups % 50 == 0) { 96 WiFi.begin(SSID, PWD); 97 while (WiFi.status() != WL_CONNECTED) 98 ; 99 configTzTime(TIMEZONE, "pool.ntp.org"); 100 } 101} 102 103void printAt(int16_t x, int16_t y, const char* text) { 104 int16_t x1, y1; 105 uint16_t w, h; 106 epd.getTextBounds(text, x, y, &x1, &y1, &w, &h); 107 epd.setCursor(x - w / 2, y + h / 2); 108 epd.print(text); 109} 110 111void printfAt(int16_t x, int16_t y, const char* format, ...) { 112 static char buff[64]; 113 va_list args; 114 va_start(args, format); 115 vsnprintf(buff, 64, format, args); 116 printAt(x, y, buff); 117} 118 119void polar2cart(float x, float y, float r, float alpha, int& cx, int& cy) { 120 alpha = alpha * TWO_PI / 360; 121 cx = int(x + r * sin(alpha)); 122 cy = int(y - r * cos(alpha)); 123} 124 125void checkButton() { 126 pinMode(BUTTON_PIN, INPUT_PULLUP); 127 if (digitalRead(BUTTON_PIN) == LOW) { 128 delay(50); // Debounce 129 if (digitalRead(BUTTON_PIN) == LOW) { 130 useRomanNumerals = !useRomanNumerals; 131 redrawDisplay(); 132 while(digitalRead(BUTTON_PIN) == LOW); // Wait for button release 133 } 134 } 135} 136 137void checkInversionButton() { 138 pinMode(INVERT_BUTTON_PIN, INPUT_PULLUP); 139 if (digitalRead(INVERT_BUTTON_PIN) == LOW) { 140 delay(50); // Debounce 141 if (digitalRead(INVERT_BUTTON_PIN) == LOW) { 142 invertedDisplay = !invertedDisplay; 143 redrawDisplay(); 144 while(digitalRead(INVERT_BUTTON_PIN) == LOW); // Wait for button release 145 } 146 } 147} 148 149void redrawDisplay() { 150 epd.setFullWindow(); 151 epd.fillScreen(getBgColor()); 152 drawDisplayFrame(); 153 drawProgressBars(); 154 drawClockFace(); 155 drawClockHands(); 156 drawDateDay(); 157 epd.display(false); 158} 159 160void drawClockFace() { 161 int cx, cy; 162 epd.setFont(&FreeSansBold9pt7b); 163 epd.setTextColor(getFgColor()); 164 165 const int FRAME_THICKNESS = 1; // Outer frame thickness 166 const int FRAME_GAP = 3; // Gap between outer and inner circles 167 168 // Draw outer thick frame 169 for(int i = 0; i < FRAME_THICKNESS; i++) { 170 epd.drawCircle(CW, CH, R + i, getFgColor()); 171 } 172 173 // Draw inner circle after the gap 174 epd.drawCircle(CW, CH, R - FRAME_GAP, getFgColor()); 175 176 // Center dot 177 epd.fillCircle(CW, CH, 8, getFgColor()); 178 179 // Draw hour markers and numbers 180 for (int h = 1; h <= 12; h++) { 181 float alpha = 360.0 * h / 12; 182 183 // Move numbers slightly inward to accommodate new frame 184 polar2cart(CW, CH, R - 25, alpha, cx, cy); 185 186 if (useRomanNumerals) { 187 const char* romanNumeral = toRoman(h); 188 printfAt(cx, cy, "%s", romanNumeral); 189 } else { 190 printfAt(cx, cy, "%d", h); 191 } 192 193 polar2cart(CW, CH, R - 45, alpha, cx, cy); 194 epd.fillCircle(cx, cy, 3, getFgColor()); 195 196 // Draw minute markers 197 for (int m = 1; m <= 12 * 5; m++) { 198 float alpha = 360.0 * m / (12 * 5); 199 polar2cart(CW, CH, R - 45, alpha, cx, cy); 200 epd.fillCircle(cx, cy, 2, getFgColor()); 201 } 202 } 203} 204 205void drawTriangle(float alpha, int width, int len) { 206 int x0, y0, x1, y1, x2, y2; 207 polar2cart(CW, CH, len, alpha, x2, y2); 208 polar2cart(CW, CH, width, alpha - 90, x1, y1); 209 polar2cart(CW, CH, width, alpha + 90, x0, y0); 210 epd.drawTriangle(x0, y0, x1, y1, x2, y2, getFgColor()); 211} 212 213void drawClockHands() { 214 struct tm t; 215 getLocalTime(&t); 216 217 // Calculate minute angle 218 float alphaM = 360.0 * (t.tm_min / 60.0); 219 220 // Calculate hour angle with smooth movement 221 float hourAngle = (t.tm_hour % 12) * 30.0; 222 float minuteContribution = (t.tm_min / 60.0) * 30.0; 223 float alphaH = hourAngle + minuteContribution; 224 225 // Draw the hands 226 drawTriangle(alphaM, 8, R - 50); // Minute hand 227 drawTriangle(alphaH, 8, R - 65); // Hour hand 228 epd.fillCircle(CW, CH, 8, getFgColor()); // Center dot 229} 230 231void drawDateDay() { 232 struct tm t; 233 getLocalTime(&t); 234 235 epd.setFont(&FreeSans9pt7b); 236 epd.setTextColor(getFgColor()); 237 238 printfAt(CW, CH+R/3, "%02d-%02d-%02d", 239 t.tm_mday, t.tm_mon + 1, t.tm_year -100); 240 printfAt(CW, CH-R/3, "%s", DAYSTR[t.tm_wday]); 241} 242 243void drawProgressBar(int x, int y, int width, int height, float percentage, const char* label) { 244 // Draw outer rectangle 245 epd.drawRect(x, y, width, height, getFgColor()); 246 247 // Calculate inner area with margin 248 int innerX = x + 3; 249 int innerY = y + 3; 250 int innerWidth = width - 6; 251 int innerHeight = height - 6; 252 253 // Calculate fill height 254 int fillHeight = (int)(innerHeight * percentage); 255 int fillTop = innerY + innerHeight - fillHeight; 256 257 // First draw the filled portion 258 epd.fillRect(innerX, fillTop, innerWidth, fillHeight, getFgColor()); 259 260 // Now draw the ticks - they'll appear correctly in both filled and empty areas 261 for(int i = 1; i < 4; i++) { 262 int tickY = innerY + (innerHeight * i / 4); 263 264 // For each pixel in the tick line 265 for(int px = innerX; px < innerX + innerWidth; px++) { 266 // If this pixel is in the filled area, use bg color, else use fg color 267 uint16_t color = (tickY >= fillTop) ? getBgColor() : getFgColor(); 268 epd.drawPixel(px, tickY, color); 269 } 270 } 271 272 // Draw label above the bar 273 epd.setFont(&FreeSans9pt7b); 274 epd.setTextColor(getFgColor()); 275 int16_t x1, y1; 276 uint16_t w, h; 277 epd.getTextBounds(label, 0, 0, &x1, &y1, &w, &h); 278 epd.setCursor(x + (width - w)/2, y - 10); 279 epd.print(label); 280} 281void drawProgressBars() { 282 struct tm t; 283 getLocalTime(&t); 284 285 float hourProgress = (t.tm_min * 60.0f + t.tm_sec) / (60.0f * 60.0f); 286 float dayProgress = (t.tm_hour * 3600.0f + t.tm_min * 60.0f + t.tm_sec) / (24.0f * 3600.0f); 287 288 int leftX = BAR_MARGIN; 289 int leftY = (H - BAR_HEIGHT)/2; 290 291 int rightX = W - BAR_MARGIN - BAR_WIDTH; 292 int rightY = (H - BAR_HEIGHT)/2; 293 294 // Draw the progress bars 295 drawProgressBar(leftX, leftY, BAR_WIDTH, BAR_HEIGHT, hourProgress, "HOUR"); 296 drawProgressBar(rightX, rightY, BAR_WIDTH, BAR_HEIGHT, dayProgress, "DAY"); 297 298 // Add elapsed time information below the bars 299 epd.setFont(&FreeSans9pt7b); 300 epd.setTextColor(getFgColor()); 301 302 // Minutes elapsed 303 char minuteStr[10]; 304 sprintf(minuteStr, "%d m", t.tm_min); 305 int16_t x1, y1; 306 uint16_t w, h; 307 epd.getTextBounds(minuteStr, 0, 0, &x1, &y1, &w, &h); 308 epd.setCursor(leftX + (BAR_WIDTH - w)/2, leftY + BAR_HEIGHT + 20); 309 epd.print(minuteStr); 310 311 // Hours elapsed 312 char hourStr[10]; 313 sprintf(hourStr, "%d h", t.tm_hour); 314 epd.getTextBounds(hourStr, 0, 0, &x1, &y1, &w, &h); 315 epd.setCursor(rightX + (BAR_WIDTH - w)/2, rightY + BAR_HEIGHT + 20); 316 epd.print(hourStr); 317} 318 319void drawClock(const void* pv) { 320 static int lastMinute = -1; 321 322 struct tm t; 323 getLocalTime(&t); 324 325 // Full refresh every minute 326 if (lastMinute != t.tm_min || wakeups == 0) { 327 epd.setFullWindow(); 328 epd.fillScreen(getBgColor()); 329 330 // Draw the display frame first 331 drawDisplayFrame(); 332 333 // Draw progress bars first (behind clock) 334 drawProgressBars(); 335 336 // Draw clock elements 337 drawClockFace(); 338 drawClockHands(); 339 drawDateDay(); 340 341 lastMinute = t.tm_min; 342 } 343} 344 345void setup() { 346 epdPower(HIGH); 347 initDisplay(); 348 setTimezone(); 349 syncTime(); 350 351 esp_sleep_wakeup_cause_t wakeup_reason = esp_sleep_get_wakeup_cause(); 352 353 if (wakeup_reason == ESP_SLEEP_WAKEUP_EXT0) { 354 checkButton(); 355 } 356 357 if (wakeup_reason == ESP_SLEEP_WAKEUP_EXT1) { 358 uint64_t wakeup_pin_mask = esp_sleep_get_ext1_wakeup_status(); 359 if (wakeup_pin_mask & (1ULL << INVERT_BUTTON_PIN)) { 360 checkInversionButton(); 361 } 362 } 363 364 drawClock(0); 365 366 wakeups = (wakeups + 1) % 1000; 367 368 epd.display(false); 369 epd.hibernate(); 370 371 // Enable wakeup from both buttons 372 esp_sleep_enable_ext0_wakeup((gpio_num_t)BUTTON_PIN, LOW); 373 esp_sleep_enable_ext1_wakeup((1ULL << INVERT_BUTTON_PIN), ESP_EXT1_WAKEUP_ANY_LOW); 374 375 struct tm t; 376 getLocalTime(&t); 377 uint64_t sleepTime = (60 - t.tm_sec) * 1000000ULL; 378 379 esp_sleep_enable_timer_wakeup(sleepTime); 380 esp_deep_sleep_start(); 381} 382 383void loop() { 384}
Documentation
Schematic
...
Schemaric.jpg

Comments
Only logged in users can leave comments