Skip to Content

Calendário Mensal no Ecrã E-Paper

Calendário Mensal no Ecrã E-Paper

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 Lolin32

ESP32 lite

USB data cable

Cabo USB de Dados

Dupont wire set

Conjunto de Fios Dupont

Half_breadboard56a

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.

Frente e verso do módulo de ecrã E-Paper de 4,2″

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.

Parte de trás do módulo de ecrã com interface SPI e controlador

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.

Connecting 4.2" e-Paper to ESP32 via SPI
Ligação do ecrã E-Paper de 4,2″ ao ESP32 via 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-PaperESP32 lite
CS/SS5
SCL/SCK 18
SDA/DIN/MOSI23
BUSY15
RES/RST2
DC0
VCC3.3V
GNDG

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.

ESP32 ligado ao ecrã E-Paper

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.

Adafruit_GFX and GxEPD2 libraries in Library Manager
Bibliotecas Adafruit_GFX e GxEPD2 no Library Manager

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:

Test output on 4.2" e-Paper display
Saída de teste no ecrã E-Paper de 4,2″

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á:

Calendar view
Vista do Calendário

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:


Right alignment without (left) and with (right) shift correction
Alinhamento à direita sem (esquerda) e com (direita) correção de deslocamento

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:

Highlighting of the current day
Destaque do dia atual

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.

Components of calendar view
Partes da vista do calendário

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 ; )