Neste tutorial vamos construir um relógio analógico usando um ecrã e-Paper de 4,2″ e um ESP32. O relógio irá sincronizar a hora com um servidor de tempo na internet (servidor SNTP). Além disso, o ESP32 e o ecrã vão entrar em modo de suspensão entre atualizações do ecrã para aumentar o tempo de funcionamento do relógio com alimentação por bateria.
Vamos começar com os componentes necessários.
Componentes Necessários
Estou a usar o ES32 lite como microprocessador, pois é barato e tem uma interface de carregamento de bateria, o que permite alimentar o relógio com uma bateria LiPo. Qualquer outro ESP32 ou ESP8266 com memória suficiente também serve, mas de preferência escolha um com interface de carregamento de bateria.

Ecrã e-Paper de 4,2″

ESP32 lite

Cabo USB de Dados

Conjunto de fios Dupont

Breadboard
Makerguides is a participant in affiliate advertising programs designed to provide a means for sites to earn advertising fees by linking to Amazon, AliExpress, Elecrow, and other sites. As an Affiliate we may earn from qualifying purchases.
Ecrã e-Paper
O ecrã e-Paper utilizado neste projeto é um módulo de 4,2″ com resolução de 400×300 píxeis, 4 níveis de cinzento, tempo de atualização parcial de 0,4 segundos e um controlador integrado com interface SPI.

Repare que o módulo tem um pequeno jumper/switch na parte de trás para alternar entre SPI de 4 fios e SPI de 3 fios. Vamos usar o SPI de 4 fios por defeito. Por isso, não é necessário alterar nada.

O módulo do ecrã funciona a 3,3V ou 5V, tem um consumo em modo de suspensão muito baixo de 0,01µA e consome apenas cerca de 26,4mW durante a atualização. Para mais informações sobre ecrãs e-Paper em geral, veja os seguintes dois tutoriais: Interfacing Arduino To An E-ink Display e Weather Station on e-Paper Display .
Ligar e Testar o e-Paper
Primeiro, vamos ligar e testar o funcionamento do e-Paper. A imagem seguinte mostra toda a cablagem para alimentação e SPI.

Abaixo está uma tabela com todas as ligações para sua conveniência. Note que pode alimentar o ecrã com 3,3V ou 5V, mas o ESP32-lite só tem saída de 3,3V e as linhas de dados SPI devem ser 3,3V!
| Ecrã e-Paper | ESP32 lite |
|---|---|
| CS/SS | 5 |
| SCL/SCK | 18 |
| SDA/DIN/MOSI | 23 |
| BUSY | 15 |
| RES/RST | 2 |
| DC | 0 |
| VCC | 3.3V |
| GND | G |
Pode usar uma breadboard para fazer todas as ligações. Mas eu liguei o ecrã diretamente ao ESP32 com fios Dupont e também adicionei uma bateria LiPo para testar o funcionamento do relógio a bateria. A imagem abaixo mostra como ficou a minha montagem.

Instalar a biblioteca GxEPD2
Antes de podermos desenhar ou escrever no ecrã e-Paper, precisamos de instalar duas bibliotecas. A Adafruit_GFX biblioteca gráfica, que fornece um conjunto comum de primitivas gráficas (texto, pontos, linhas, círculos, etc.). E a GxEPD2 biblioteca, que fornece o driver gráfico para o ecrã e-Paper.
Basta instalar as bibliotecas da forma habitual. Depois de instaladas, devem aparecer no Library Manager como mostrado abaixo.

Código de teste
Depois de instalar a biblioteca, execute o seguinte código de teste para garantir que tudo está a funcionar.
#include "GxEPD2_BW.h"
//CS(SS)=5, SCL(SCK)=18, SDA(MOSI)=23, BUSY=15, RES(RST)=2, DC=0
GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
void setup() {
epd.init(115200, true, 50, false);
epd.setRotation(1);
epd.setTextColor(GxEPD_BLACK);
epd.setTextSize(2);
epd.setFullWindow();
epd.fillScreen(GxEPD_WHITE);
epd.setCursor(90, 190);
epd.print("Makerguides");
epd.display();
epd.hibernate();
}
void loop() {}
O código de teste imprime o texto “Makerguides” e, após algum piscar do ecrã (atualização completa), deve vê-lo no seu ecrã como mostrado abaixo:

Se não aparecer, algo está errado. Provavelmente o ecrã não está corretamente ligado ou foi escolhido o driver errado. A linha de código crítica é esta, onde especificamos o driver do ecrã de 4,2″:
GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
O Readme da GxEPD2 biblioteca lista todos os ecrãs suportados e pode encontrar os detalhes nos ficheiros header, por exemplo GxEPD2.h . Procure o driver específico para o seu ecrã. Pode ser necessário algum teste e erro.
Código para um Relógio Analógico em e-Paper
O código seguinte é o código completo para um relógio analógico que sincroniza automaticamente a hora com um servidor de tempo na internet (SNTP) e mostra a hora e a data num ecrã e-Paper de 4,2″. Entre atualizações, o ecrã e o ESP32 entram em modo de suspensão para poupar bateria. Dê uma vista de olhos rápida ao código e depois vamos analisar os detalhes.
#include "GxEPD2_BW.h"
#include "Fonts/FreeSans9pt7b.h"
#include "Fonts/FreeSansBold9pt7b.h"
#include "WiFi.h"
#include "esp_sntp.h"
const char* TIMEZONE = "AEST-10AEDT,M10.1.0,M4.1.0/3";
const char* SSID = "SSID";
const char* PWD = "PASSWORD";
const char* DAYSTR[] = {
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
};
// W, H flipped due to setRotation(1)
const int H = GxEPD2_420_GDEY042T81::WIDTH;
const int W = GxEPD2_420_GDEY042T81::HEIGHT;
const int CW = W / 2;
const int CH = H / 2;
const int R = min(W, H) / 2;
const uint16_t WHITE = GxEPD_WHITE;
const uint16_t BLACK = GxEPD_BLACK;
RTC_DATA_ATTR uint16_t wakeups = 0;
GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
void initDisplay() {
bool initial = wakeups == 0;
epd.init(115200, initial, 50, false);
epd.setRotation(1);
epd.setTextSize(1);
epd.setTextColor(BLACK);
}
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 drawClockFace() {
int cx, cy;
epd.setFont(&FreeSansBold9pt7b);
epd.drawCircle(CW, CH, R - 2, BLACK);
epd.fillCircle(CW, CH, 8, BLACK);
for (int h = 1; h <= 12; h++) {
float alpha = 360.0 * h / 12;
polar2cart(CW, CH, R - 20, alpha, cx, cy);
printfAt(cx, cy, "%d", h);
polar2cart(CW, CH, R - 40, alpha, cx, cy);
epd.fillCircle(cx, cy, 3, BLACK);
}
for (int m = 1; m <= 12 * 5; m++) {
float alpha = 360.0 * m / (12 * 5);
polar2cart(CW, CH, R - 40, alpha, cx, cy);
epd.fillCircle(cx, cy, 2, BLACK);
}
}
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, BLACK);
}
void drawClockHands() {
struct tm t;
getLocalTime(&t);
float alphaM = 360 * (t.tm_min / 60.0);
float alphaH = 30 * (t.tm_hour % 12);
drawTriangle(alphaM, 8, R - 50);
drawTriangle(alphaH, 8, R - 65);
}
void drawDateDay() {
struct tm t;
getLocalTime(&t);
epd.setFont(&FreeSans9pt7b);
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 drawClock(const void* pv) {
if (wakeups % 120 == 0) {
epd.setFullWindow();
} else {
epd.setPartialWindow(0, 0, W, H);
}
epd.fillScreen(WHITE);
drawClockFace();
drawClockHands();
drawDateDay();
}
void setup() {
initDisplay();
setTimezone();
syncTime();
epd.drawPaged(drawClock, 0);
epd.hibernate();
wakeups = (wakeups + 1) % 1000;
esp_sleep_enable_timer_wakeup(30 * 1000 * 1000);
esp_deep_sleep_start();
}
void loop() {
}
Se carregar o código no seu ESP32, deve ver o seguinte relógio apresentado:

Mostra o mostrador do relógio, os dois ponteiros e o dia e data atuais.
Bibliotecas
Começamos por incluir o ficheiro header GxEPD2_BW.h para o ecrã e-Paper preto e branco (BW). Se tiver um ecrã de 3 cores, deve incluir GxEPD2_3C.h , ou GxEPD2_4C.h para um ecrã de 4 cores, e GxEPD2_7C.h para um ecrã de 7 cores.
#include "GxEPD2_BW.h" #include "Fonts/FreeSans9pt7b.h" #include "Fonts/FreeSansBold9pt7b.h"
Incluímos também dois ficheiros para as fontes usadas para mostrar os rótulos do relógio e a data. Os rótulos do relógio estão numa fonte negrito ( FreeSansBold9pt7b ) e a data está numa fonte mais fina ( FreeSans9pt7b ) – veja a imagem do relógio acima. Pode encontrar uma visão geral das AdaFruit GFX fonts aqui.
Por fim, incluímos as bibliotecas WiFi.h e esp_snt.h , que vamos precisar para sincronizar o relógio com um servidor de tempo SNTP via Wi-Fi.
#include "WiFi.h" #include "esp_sntp.h"
Constantes
De seguida, definimos algumas constantes. Mais importante, terá de definir o SSID e PASSWORD da sua rede WiFi, e o TIMEZONE onde vive.
const char* SSID = "SSID"; const char* PWD = "PASSWORD"; const char* TIMEZONE = "AEST-10AEDT,M10.1.0,M4.1.0/3";
A especificação de fuso horário acima ” AEST-10AEDT,M10.1.0,M4.1.0/3 ” é para a Austrália, que corresponde ao horário padrão da Austrália Oriental (AEST) com ajustes para horário de verão.
As partes desta definição de fuso horário são as seguintes
- AEST: Horário Padrão da Austrália Oriental
- -10: Desvio UTC de 10 horas à frente do Tempo Universal Coordenado (UTC)
- AEDT: Horário de Verão da Austrália Oriental
- M10.1.0: Mudança para horário de verão ocorre no 1º domingo de outubro
- M4.1.0/3: Mudança de volta para o horário padrão ocorre no 1º domingo de abril, com uma diferença de 3 horas em relação ao UTC.
Para outras definições de fuso horário, consulte o Posix Timezones Database . Basta copiar e colar a string que encontrar lá e alterar a constante TIMEZONE em conformidade.
Além da data, também queremos mostrar o nome do dia atual, por exemplo, quinta-feira. A constante DAYSTR define os nomes dos dias. Sinta-se à vontade para mudar o idioma ou escolher nomes mais curtos, mas mantenha a ordem.
const char* DAYSTR[] = {
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
};
A seguir, redefinimos constantes para a largura W e altura H do ecrã. Isto é apenas por conveniência. Note que a largura e altura estão trocadas, pois rodamos o ecrã ( setRotation(1) ) na função initDisplay .
const int H = GxEPD2_420_GDEY042T81::WIDTH; const int W = GxEPD2_420_GDEY042T81::HEIGHT;
Por fim, definimos constantes para a posição central ( CW , CH ) no ecrã, o raio máximo R de um círculo no ecrã, e nomes mais curtos para as cores preto e branco.
const int CW = W / 2; const int CH = H / 2; const int R = min(W, H) / 2; const uint16_t WHITE = GxEPD_WHITE; const uint16_t BLACK = GxEPD_BLACK;
Variáveis e Objetos
Depois das constantes, definimos uma variável global para contar os wakeups e o objeto do ecrã.
RTC_DATA_ATTR uint16_t wakeups = 0; GxEPD2_BW<GxEPD2_420_GDEY042T81, GxEPD2_420_GDEY042T81::HEIGHT> epd(GxEPD2_420_GDEY042T81(5, 0, 2, 15));
A variável wakeups é incrementada sempre que o ESP32 acorda do deep-sleep. É armazenada em RTC memory para que mantenha o valor mesmo quando o ESP32 entra em modo deep-sleep. Usamo-la para decidir quando fazer uma atualização total ou parcial do ecrã, e com que frequência sincronizar a hora.
O objeto epd representa o ecrã ( e – p aper d isplay). Esta definição de objeto deve corresponder ao tipo de ecrã e-Paper que tem. Aqui temos um ecrã de 4,2 polegadas (= _420). Veja o Readme da GxEPD2 biblioteca e o ficheiro GxEPD2.h para os ecrãs suportados.
Função initDisplay
A função initDisplay inicializa o ecrã, define a orientação para horizontal, o tamanho do texto para 1 e a cor do texto para preto.
void initDisplay() {
bool initial = wakeups == 0;
epd.init(115200, initial, 50, false);
epd.setRotation(1);
epd.setTextSize(1);
epd.setTextColor(BLACK);
}
epd.init() normalmente provoca uma atualização total do ecrã quando executada. No entanto, queremos uma atualização total apenas após reiniciar o ESP32 e uma atualização parcial ao acordar do deep sleep. Para isso, definimos o parâmetro initial da função epd.init() para true quando wakeups == 0 , ou seja, após um reset, e caso contrário initial é false, para uma atualização parcial.
Função setTimezone
A função setTimezone define o TIMEZONE . Temos de a chamar sempre na função setup, pois após deep-sleep ou reset a informação do fuso horário é perdida.
void setTimezone() {
setenv("TZ", TIMEZONE, 1);
tzset();
}
Função syncTime
A função syncTime cria uma ligação WiFi e depois sincroniza o relógio interno do ESP32 com o servidor SNTP ” pool.ntp.org ” chamando configTzTime() .
Pode especificar outros servidores SNTP, ou até vários, para se ligar. Veja o tutorial How to synchronize ESP32 clock with SNTP server para mais informações.
void syncTime() {
if (wakeups % 50 == 0) {
WiFi.begin(SSID, PWD);
while (WiFi.status() != WL_CONNECTED)
;
configTzTime(TIMEZONE, "pool.ntp.org");
}
}
No entanto, queremos sincronizar a hora apenas de vez em quando. Como o ESP32 está configurado para dormir durante 30 segundos (ver função loop ), a expressão wakeups % 50 == 0 garante que a hora só é sincronizada a cada 25 minutos (30seg * 50 / 60seg).
Pode alterar isto, mas tenha em conta que sincronizações mais frequentes consomem mais bateria (o Wi-Fi consome muita energia). Sincronizações menos frequentes, por outro lado, fazem com que o relógio reaja mais lentamente à mudança para o horário de verão.
Funções printAt
As funções printAt mostram texto centrado nas coordenadas especificadas do ecrã.
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);
}
Enquanto printAt() apenas imprime texto simples, a função printfAt() funciona como printf e permite-lhe fornecer um especificador de formato, por exemplo printfAt(x, y, "%02d-%02d-%02d", m, h, y);
Função polar2cart
A função polar2cart converte polar coordinates dados em coordenadas polares, dados por um raio r e um ângulo alpha em cartesian coordinates cx, cy .
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));
}
Esta função é muito útil, pois dados de tempo como 8:15 são essencialmente coordenadas polares no relógio e temos de as converter para o sistema de coordenadas cartesianas usado pelo ecrã.
Função drawClockFace
A função drawClockFace() , como o nome indica, desenha o mostrador do relógio. Isto inclui a moldura, as marcas dos minutos e os rótulos e marcas das horas. Como pode ver, faz bom uso da função polar2cart() que discutimos antes.
void drawClockFace() {
int cx, cy;
epd.setFont(&FreeSansBold9pt7b);
// Frame
epd.drawCircle(CW, CH, R - 2, BLACK);
epd.fillCircle(CW, CH, 8, BLACK);
// Hour ticks and labels
for (int h = 1; h <= 12; h++) {
float alpha = 360.0 * h / 12;
polar2cart(CW, CH, R - 20, alpha, cx, cy);
printfAt(cx, cy, "%d", h);
polar2cart(CW, CH, R - 40, alpha, cx, cy);
epd.fillCircle(cx, cy, 3, BLACK);
}
// Minute ticks
for (int m = 1; m <= 12 * 5; m++) {
float alpha = 360.0 * m / (12 * 5);
polar2cart(CW, CH, R - 40, alpha, cx, cy);
epd.fillCircle(cx, cy, 2, BLACK);
}
}
Função drawTriangle
A função drawTriangle() desenha um triângulo, mas em coordenadas polares. A base do triângulo está no centro do relógio e o triângulo está inclinado com o ângulo alpha . width é a largura da base e len é o comprimento do triângulo. Esta função é usada para desenhar os ponteiros do relógio.
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, BLACK);
}
Pode mudar de epd.drawTriangle() para epd.fillTriangle() , se preferir ponteiros preenchidos. São mais fáceis de ver, mas vão tapar o dia e a data atuais.
Função drawClockHands
O desenho real dos ponteiros do relógio é feito pela função drawClockHands() . Obtém a hora local atual, converte minutos e horas em ângulos e depois usa drawTriangle() para desenhar os ponteiros.
void drawClockHands() {
struct tm t;
getLocalTime(&t);
float alphaM = 360 * (t.tm_min / 60.0);
float alphaH = 30 * (t.tm_hour % 12);
drawTriangle(alphaM, 8, R - 50);
drawTriangle(alphaH, 8, R - 65);
}
Função drawDateDay
Além da hora mostrada pelos ponteiros, também quis mostrar a data e o dia da semana atuais. A função drawDateDay() obtém a hora e data locais e depois imprime o nome do dia, por exemplo quinta-feira, no topo do mostrador e a data, por exemplo 05-09-24, em baixo. Pode facilmente mudar o especificador de formato para adaptar a data ao seu país.
void drawDateDay() {
struct tm t;
getLocalTime(&t);
epd.setFont(&FreeSans9pt7b);
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]);
}
Função drawClock
A função drawClock() junta tudo. Limpa o ecrã e depois desenha o mostrador, os ponteiros e a data.
void drawClock(const void* pv) {
if (wakeups % 120 == 0) {
epd.setFullWindow();
} else {
epd.setPartialWindow(0, 0, W, H);
}
epd.fillScreen(WHITE);
drawClockFace();
drawClockHands();
drawDateDay();
}
Dependendo do contador wakeups é feita uma atualização total ou parcial do ecrã. Uma atualização total é lenta (2-4 segundos) e provoca muito piscar do ecrã. Uma atualização parcial é muito mais rápida (<0,5 seg) e sem piscar.
No entanto, uma atualização parcial deixa uma imagem residual ténue do conteúdo antigo. Se olhar com atenção para a imagem abaixo, pode ver as imagens residuais do ponteiro dos minutos enquanto se moveu do 5 para o 10.

Para remover as imagens residuais, ocasionalmente queremos fazer uma atualização total do ecrã. Na função drawClock() fazemos isto quando o contador wakeup atinge 120: wakeups % 120 == 0 . Como o tempo de deep-sleep está definido para 30 segundos, isto significa uma atualização total a cada 30seg*120/60seg = 60 minutos.
Funções setup e loop
Por fim, temos as habituais funções setup e loop . A função loop está vazia, pois o ESP32 entra em deep-sleep no final da função setup e a função loop nunca é executada.
void setup() {
initDisplay();
setTimezone();
syncTime();
epd.drawPaged(drawClock, 0);
epd.hibernate();
wakeups = (wakeups + 1) % 1000;
esp_sleep_enable_timer_wakeup(30 * 1000 * 1000);
esp_deep_sleep_start();
}
void loop() {
}
A função setup começa por inicializar o ecrã, definir o fuso horário e (talvez) sincronizar a hora (dependendo do contador de wakeup).
Depois chamamos drawPaged para executar as funções drawClock e depois hibernamos o ecrã para poupar energia. Se precisar de mais detalhes, veja o tutorial Partial Refresh of e-Paper Display , onde explicamos a função drawPaged em mais detalhe.
Antes de colocar o ESP32 durante 30 segundos (30 * 1000 * 1000 microssegundos) em deep-sleep, incrementamos o contador wakeup e calculamos o módulo 1000 para evitar overflow do contador.
E é isto! Com este código tem um relógio analógico sempre preciso e que pode funcionar a bateria.
Conclusão
Neste tutorial aprendeu a construir um relógio analógico num ecrã e-Paper de 4,2″ que sincroniza a hora com um servidor SNTP e mostra sempre a hora e data corretas. O relógio utiliza o modo deep-sleep do ESP32 para reduzir o consumo de energia e aumentar o tempo de funcionamento com bateria.
Se usar o ESP32 LOLIN Lite sugerido, ligar uma bateria LiPo recarregável é simples e pode usar um MAX1704X para monitorizar os níveis de carga.
Por fim, se preferir um relógio digital em vez de um analógico, veja o nosso tutorial Digital Clock on e-Paper Display .
Boas experiências ; )

