Neste tutorial, vais aprender a implementar um Calendário Mensal num Ecrã E-Paper usando um ESP32. O ecrã também mostrará a hora atual e sincronizará o tempo com um servidor de tempo na internet (servidor SNTP), para que a hora e o calendário estejam sempre corretos. Já não precisas de ajustar manualmente a hora e a data.
Peças Necessárias
Estou a usar o ESP32 lite como microprocessador, pois é barato e tem uma interface de carregamento de bateria, o que permite correr o calendário numa bateria LiPo. Qualquer outro ESP32 ou ESP8266 com memória suficiente também funciona, mas de preferência escolhe um com interface de carregamento de bateria. Deverás conseguir usar um Arduino também, mas eu não experimentei.

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 usado neste projeto é um módulo de 4,2″, com resolução de 400×300 pixels, 4 níveis de cinzento, tempo de atualização parcial de 0,4 segundos e um controlador embutido com interface SPI.

Repara que o módulo tem um pequeno jumper/chave 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 aqui, por isso não precisas de mudar nada.

O módulo do ecrã funciona a 3,3V ou 5V, tem uma corrente de sono muito baixa 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, vê os dois tutoriais seguintes: Interfacing Arduino To An E-ink Display e Weather Station on e-Paper Display.
Ligação e Teste do E-Paper
Primeiro, vamos ligar e testar o funcionamento do ecrã E-Paper. A imagem seguinte mostra toda a ligação para alimentação e SPI.

Abaixo está uma tabela com todas as ligações para conveniência. Repara que podes alimentar o ecrã com 3,3V ou 5V, mas o ESP32-lite tem apenas uma saída de 3,3V e as linhas de dados SPI devem ser de 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 |
Podes usar uma breadboard para ligar tudo. Mas eu liguei o ecrã diretamente ao ESP32 com fios Dupont. A imagem abaixo mostra como ficou o meu setup.

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 software do driver gráfico para o E-Paper Display.
Basta instalar as bibliotecas da forma habitual. Após a instalação, elas devem aparecer no Library Manager assim.

Código de teste
Depois de instalares a biblioteca, executa o seguinte código de teste para garantir que tudo funciona.
#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 imprime o texto “Makerguides” e, após algum piscar do ecrã (atualização completa), deverás vê-lo no teu ecrã como mostrado abaixo:

Se não aparecer, algo está errado. Muito provavelmente o ecrã não está ligado corretamente ou o driver do ecrã escolhido está errado. A linha crítica do código é 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));
A Readme GxEPD2 biblioteca lista todos os ecrãs suportados e podes encontrar os detalhes nos ficheiros header, por exemplo GxEPD2.h. Encontra o driver específico para o teu ecrã. Isto pode requerer alguma tentativa e erro.
Código para um Calendário no E-Paper
Nesta secção vamos escrever o código para o nosso Calendário. A captura de ecrã abaixo mostra como ficará:

O dia e hora atuais são mostrados no topo. Abaixo, o ecrã mostra o mês atual, o ano e os dias do mês com o dia atual assinalado.
Abaixo está o código para este calendário. Dá uma vista rápida para teres uma ideia geral e depois vamos aprofundar os detalhes:
#include <WiFi.h>
#include <time.h>
#include <GxEPD2_BW.h>
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSansBold9pt7b.h>
#include <Fonts/FreeSansBold12pt7b.h>
#include <Fonts/FreeSansBold24pt7b.h>
const char* ssid = "SSID";
const char* password = "PWD";
const char* ntpServer = "pool.ntp.org";
const char* timezone = "AEST-10AEDT,M10.1.0,M4.1.0/3";
const char* dayNames[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
char* dayLongNames[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" };
const char* monthNames[] = {
"January", "February", "March", "April", "May",
"June", "July", "August", "September", "October", "November", "December"
};
const int shifts[] = {
3, 0, 0, 1, 0, 0, 0, 0, 0, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1
};
//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));
bool isLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
int daysInMonth(int year, int month) {
int days[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && isLeapYear(year)) {
return 29;
}
return days[month-1];
}
// Return day of the week (0=Sunday, 1=Monday, ..., 6=Saturday)
int dayOfWeek(int year, int month, int day) {
tm timeinfo = {};
timeinfo.tm_year = year - 1900;
timeinfo.tm_mon = month - 1;
timeinfo.tm_mday = day;
mktime(&timeinfo);
return timeinfo.tm_wday;
}
void drawAt(int x, int y, const char* text, int shift, bool mark) {
int16_t xb, yb;
uint16_t wb, hb;
epd.getTextBounds(text, 0, 0, &xb, &yb, &wb, &hb);
epd.setCursor(x - wb - shift, y);
epd.setTextColor(mark ? GxEPD_WHITE : GxEPD_BLACK);
if (mark) {
epd.fillRect(x - wb - shift - 3, y - hb - 3, wb + 8, hb + 8, GxEPD_BLACK);
}
epd.print(text);
}
void drawCalendar(int year, int month, int day) {
char buf[6];
int dy = 25;
int dx = 56;
int xoff = 10;
int yoff = 100;
// Print name of month and the year
epd.setPartialWindow(0, yoff - 20, 400, 300 - (yoff - 20));
epd.fillRect(6, yoff - 20, 400 - 12, 28, GxEPD_BLACK);
epd.setFont(&FreeSansBold12pt7b);
epd.setTextColor(GxEPD_WHITE);
epd.setCursor(xoff, yoff);
epd.printf("%s %d", monthNames[month - 1], year);
// print names of week days
epd.setTextColor(GxEPD_BLACK);
epd.setFont(&FreeSansBold9pt7b);
xoff += 30;
for (int i = 0; i < 7; i++) {
int x = xoff + i * dx;
int y = yoff + 35;
drawAt(x, y, dayNames[i], 0, false);
}
// Print days of the month
int days = daysInMonth(year, month);
int firstDay = dayOfWeek(year, month, 1);
int x = xoff;
int y = yoff + 65;
for (int i = 0; i < firstDay; i++) {
x += dx; // shift pos of first day
}
epd.setFont(&FreeSans9pt7b);
for (int d = 1; d <= days; d++) {
sprintf(buf, "%d", d);
drawAt(x, y, buf, shifts[d - 1], d == day);
x += dx;
if ((firstDay + d) % 7 == 0) {
y += dy;
x = xoff;
}
}
}
void updateTime(const void* pv) {
struct tm t;
getLocalTime(&t);
epd.setTextColor(GxEPD_BLACK);
epd.setFont(&FreeSansBold24pt7b);
epd.setCursor(10, 60);
epd.setPartialWindow(0, 0, 400, 80);
epd.printf("%s %02d:%02d", dayLongNames[t.tm_wday], t.tm_hour, t.tm_min);
}
void updateCalendar(const void* pv) {
struct tm t;
getLocalTime(&t);
drawCalendar(t.tm_year + 1900, t.tm_mon + 1, t.tm_mday);
}
bool isNewDay() {
struct tm t;
getLocalTime(&t);
return t.tm_hour == 0 && t.tm_min == 0;
}
void syncTime() {
WiFi.begin(ssid, password);
for (int i = 0; i < 10 && WiFi.status() != WL_CONNECTED; i++) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED)
configTzTime(timezone, ntpServer);
}
void clearScreen() {
epd.setFullWindow();
epd.fillScreen(GxEPD_WHITE);
epd.display();
}
void initEPD() {
epd.init(115200, true, 50, false);
epd.setRotation(0);
}
void setup() {
Serial.begin(115200);
syncTime();
initEPD();
clearScreen();
epd.drawPaged(updateCalendar, 0);
epd.hibernate();
}
void loop() {
if (isNewDay()) {
syncTime();
clearScreen();
epd.drawPaged(updateCalendar, 0);
}
epd.drawPaged(updateTime, 0);
epd.hibernate();
delay(60 * 1000); // 60 seconds
}
Bibliotecas
O código começa por incluir as bibliotecas necessárias para conectividade WiFi, gestão do tempo e funcionalidade do ecrã E-Paper.
#include <WiFi.h> #include <time.h> #include <GxEPD2_BW.h>
Fontes
Depois incluímos as fontes usadas. Podes escolher outras e, desde que tenham o mesmo tamanho (9pt, 12pt, 24pt), o layout do calendário deve funcionar. Para a lista de fontes disponíveis vê here.
#include <Fonts/FreeSans9pt7b.h> #include <Fonts/FreeSansBold9pt7b.h> #include <Fonts/FreeSansBold12pt7b.h> #include <Fonts/FreeSansBold24pt7b.h>
Constantes
De seguida, definimos algumas constantes para as credenciais WiFi, servidor NTP, fuso horário, arrays para nomes dos dias e meses e correções de layout.
const char* ssid = "SSID";
const char* password = "PWD";
const char* ntpServer = "pool.ntp.org";
const char* timezone = "AEST-10AEDT,M10.1.0,M4.1.0/3";
const char* dayNames[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
char* dayLongNames[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" };
const char* monthNames[] = {
"January", "February", "March", "April", "May",
"June", "July", "August", "September", "October", "November", "December"
};
const int shifts[] = {
3, 0, 0, 1, 0, 0, 0, 0, 0, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1
};
Terás de substituir as constantes SSID e PASSWORD pelas tuas credenciais WiFi, e provavelmente o TIMEZONE do local onde vives também.
A especificação do fuso horário “AEST-10AEDT,M10.1.0,M4.1.0/3” é para a Austrália, que corresponde ao Australian Eastern Standard Time (AEST) com ajustes de horário de verão. Para outras definições de fuso horário, vê o Posix Timezones Database. Basta copiar e colar a string que encontrares lá e alterar a constante TIMEZONE em conformidade.
Deslocamentos
As constantes para os nomes dos dias e meses são óbvias, mas a constante shifts requer alguma explicação. Os nomes dos dias e os dias do mês na vista do calendário são desenhados alinhados à direita. Uso a função getTextBounds() para conseguir isso. No entanto, aparentemente os limites de texto calculados por esta função não são totalmente precisos e o alinhamento à direita também não. Vê abaixo:

A constante shifts desloca o alinhamento à direita pelo número especificado de pixels para um dia específico, por exemplo, shifts[0] desloca o dia 1 três pixels para a direita. A imagem acima compara o alinhamento das colunas dos dias sem (esquerda) e com (direita) a correção de deslocamento. Podes ver que os números dos dias à esquerda não estão perfeitamente alinhados à direita, o que é desconfortável quando olhas para o calendário completo. As constantes de deslocamento corrigem isso.
Configuração do Ecrã
A linha de código seguinte cria o objeto do ecrã e liga-o às conexões dos pinos para CS, SCL, SDA, BUSY, RES e DC.
//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));
Se usares um ecrã E-Paper diferente, podes ter de escolher um driver diferente aí. A biblioteca Readme GxEPD2 lista todos os ecrãs suportados e podes encontrar os detalhes nos ficheiros header, por exemplo GxEPD2.h e GxEPD2_display_selection_new_style.h.
Funções de Data
De seguida temos algumas funções de data. A função isLeapYear() retorna true se o year dado for um ano bissexto.
bool isLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
A função daysInMonth() retorna o número de dias num dado mês, considerando anos bissextos em fevereiro.
int daysInMonth(int year, int month) {
int days[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && isLeapYear(year)) {
return 29;
}
return days[month - 1];
}
E a função dayOfWeek() calcula o índice do dia da semana para uma data dada. Retorna valores de 0 (domingo) a 6 (sábado).
int dayOfWeek(int year, int month, int day) {
tm timeinfo = {};
timeinfo.tm_year = year - 1900;
timeinfo.tm_mon = month - 1;
timeinfo.tm_mday = day;
mktime(&timeinfo);
return timeinfo.tm_wday;
}
Funções de Desenho
Função drawAt
A função drawAt() é responsável por desenhar texto no ecrã E-Paper em coordenadas especificadas, com marcação opcional.
void drawAt(int x, int y, const char* text, int shift, bool mark) {
int16_t xb, yb;
uint16_t wb, hb;
epd.getTextBounds(text, 0, 0, &xb, &yb, &wb, &hb);
epd.setCursor(x - wb - shift, y);
epd.setTextColor(mark ? GxEPD_WHITE : GxEPD_BLACK);
if (mark) {
epd.fillRect(x - wb - shift - 3, y - hb - 3, wb + 8, hb + 8, GxEPD_BLACK);
}
epd.print(text);
}
A marcação é usada para destacar o dia atual, sublinhando-o com fundo preto e escrevendo o texto a branco. Abaixo um exemplo de como fica para o dia 6:

Repara no parâmetro shift, que é usado para corrigir o alinhamento à direita dos dias, como descrito acima. Obtemos os limites do texto chamando getTextBounds(), depois deslocamos o texto para a direita subtraindo a largura wb do texto da posição de desenho x e também subtraindo a correção shift.
Função drawCalendar
A função drawCalendar() desenha o calendário completo para um dia, mês e ano especificados.
void drawCalendar(int year, int month, int day) {
...
// Print name of month and the year
epd.setPartialWindow(0, yoff - 20, 400, 300 - (yoff - 20));
epd.fillRect(6, yoff - 20, 400 - 12, 28, GxEPD_BLACK);
epd.setFont(&FreeSansBold12pt7b);
epd.setTextColor(GxEPD_WHITE);
epd.setCursor(xoff, yoff);
epd.printf("%s %d", monthNames[month - 1], year);
// Print names of week days
epd.setTextColor(GxEPD_BLACK);
epd.setFont(&FreeSansBold9pt7b);
xoff += 30;
for (int i = 0; i < 7; i++) {
int x = xoff + i * dx;
int y = yoff + 35;
drawAt(x, y, dayNames[i], 0, false);
}
// Print days of the month
int days = daysInMonth(year, month);
int firstDay = dayOfWeek(year, month, 1);
int x = xoff;
int y = yoff + 65;
for (int i = 0; i < firstDay; i++) {
x += dx; // shift pos of first day
}
epd.setFont(&FreeSans9pt7b);
for (int d = 1; d <= days; d++) {
sprintf(buf, "%d", d);
drawAt(x, y, buf, shifts[d - 1], d == day);
x += dx;
if ((firstDay + d) % 7 == 0) {
y += dy;
x = xoff;
}
}
}
A vista do calendário está essencialmente dividida em quatro partes. A primeira parte (1) no topo mostra o nome do dia atual e a hora atual. A segunda parte (2) mostra o mês e ano atuais. A parte (3) mostra os nomes dos dias. E a parte (4) mostra os dias do mês.

Função updateTime
A função drawCalendar() mostrada acima desenha as partes 2 a 4, enquanto a função updateTime() abaixo desenha a parte 1. Ela obtém a hora local atual e exibe-a.
void updateTime(const void* pv) {
struct tm t;
getLocalTime(&t);
epd.setTextColor(GxEPD_BLACK);
epd.setFont(&FreeSansBold24pt7b);
epd.setCursor(10, 60);
epd.setPartialWindow(0, 0, 400, 100);
epd.printf("%s %02d:%02d", dayLongNames[t.tm_wday], t.tm_hour, t.tm_min);
}
Função updateCalendar
De forma semelhante, a função updateCalendar() obtém a data atual e depois chama drawCalendar() para atualizar o calendário mostrado.
void updateCalendar(const void* pv) {
struct tm t;
getLocalTime(&t);
drawCalendar(t.tm_year + 1900, t.tm_mon + 1, t.tm_mday);
}
Repara que ambas as funções de atualização, updateTime() e updateCalendar() fazem uma atualização parcial, que é mais rápida e evita o piscar do ecrã. Se quiseres saber mais sobre a operação de atualização parcial, vê o tutorial Partial Refresh of e-Paper Display.
Sincronização de Tempo
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().
void syncTime() {
WiFi.begin(ssid, password);
for (int i = 0; i < 10 && WiFi.status() != WL_CONNECTED; i++) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED)
configTzTime(timezone, ntpServer);
}
Podes especificar outros servidores SNTP, ou mesmo múltiplos, para ligar. Vê o tutorial How to synchronize ESP32 clock with SNTP server para mais informações.
Repara que a função syncTime() tenta apenas 10 vezes ligar ao WiFi e depois desiste. Isto tem a vantagem de o calendário continuar a funcionar, mesmo que não haja WiFi disponível. Caso contrário, o código ficaria bloqueado aqui até o WiFi estar disponível.
Função Setup
Na função setup(), inicializamos a comunicação serial, sincronizamos o tempo, inicializamos o ecrã E-Paper, limpamos o ecrã e desenhamos o calendário.
void setup() {
Serial.begin(115200);
syncTime();
initEPD();
clearScreen();
epd.drawPaged(updateCalendar, 0);
epd.hibernate();
}
Isto significa que, sempre que o ESP32 é reiniciado, o tempo é sincronizado e não precisas de te preocupar em ajustar manualmente a hora e a data.
Função Loop
A função loop() verifica se começou um novo dia, sincroniza o tempo se assim for, limpa o ecrã e atualiza o calendário e a hora mostrados.
void loop() {
if (isNewDay()) {
syncTime();
clearScreen();
epd.drawPaged(updateCalendar, 0);
}
epd.drawPaged(updateTime, 0);
epd.hibernate();
delay(60 * 1000); // 60 seconds
}
O calendário é redesenhado/atualizado apenas uma vez por dia, enquanto a hora é atualizada a cada 60 segundos. Todas estas atualizações são parciais, rápidas e sem piscar.
No entanto, a função clearScreen() que é chamada uma vez por dia, quando o calendário é atualizado, faz uma atualização completa. Isto serve para evitar o “burn in” das imagens residuais da atualização parcial. O Waveshare Manual tem mais informações sobre isto.
Repara que a sincronização diária do tempo trata as mudanças do horário de verão, mas haverá algumas horas em que o relógio estará errado. Podes evitar isto sincronizando a cada 30 minutos, por exemplo. Vê o tutorial Digital Clock on e-Paper Display para código de exemplo.
Conclusões
Neste tutorial aprendeste a implementar um Calendário Mensal num Ecrã E-Paper usando um ESP32.
Os ecrãs E-Paper consomem muito pouca energia e são ótimos para projetos alimentados por bateria. Podes correr o ESP32 com o calendário numa bateria LiPo, mas neste caso seria melhor colocar o ESP32 em modo deep-sleep entre as atualizações do ecrã. Não mostrei como fazer isso neste tutorial, mas o Digital Clock on e-Paper Display tem código de exemplo para isso.
Além disso, para um calendário alimentado por bateria, seria interessante monitorizar o nível de carga da bateria LiPo e mostrar um pequeno símbolo de bateria. Vê o tutorial Monitor Battery Levels with a MAX1704X se quiseres aprender mais sobre monitorização de bateria.
Para além da carga da bateria, poderias também mostrar a temperatura ambiente ou dados meteorológicos. Vê o tutorial Weather Station on e-Paper Display para mais sobre isso.
Se tiveres algum comentário, sente-te à vontade para deixar na secção de comentários.
Boas experiências a criar ; )

