Hackster is hosting Impact Spotlights: Robotics. Watch the stream live on Thursday!Hackster is hosting Impact Spotlights: Robotics. Stream on Thursday!
Mirko Pavleski
Published © GPL3+

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

BeginnerFull instructions provided2 hours428
Building an E-Paper Analog Clock with ESP32 - Full Tutorial

Things used in this project

Hardware components

CrowPanel ESP32 4.2” E-paper Display module
×1
Battery, 3.7 V
Battery, 3.7 V
×1
Slide Switch
Slide Switch
×1

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free

Story

Read more

Schematics

Schematic

...

Code

Code

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

Credits

Mirko Pavleski
191 projects • 1464 followers

Comments