Um Widget Display é um dispositivo embarcado construído com ESP32 que exibe três modos de informação em uma tela OLED.
Os três modos exibidos são: relógio sincronizado por NTP, dados de clima em tempo real via API e um visualizador de espectro de áudio animado.
O ambiente de simulação utilizado foi o Wokwi, já que não tinha o hardware em mãos durante o desenvolvimento.
Primeiramente, defini como o software seria organizado. O display tem 128×64 pixels e dividi ele em três regiões fixas:
struct Regiao { int x, y, w, h; };
const Regiao CIMA = {0, 0, 128, 12}; // cabeçalho
const Regiao CORPO = {0, 13, 128, 43}; // corpo
const Regiao BAIXO = {0, 57, 128, 7}; // rodapéUtilizei essa separação em structs para evitar espalhar códigos de coordenadas pelo código. Toda função de renderização sabe exatamente onde pode desenhar..
O sistema de modos foi feito como um enum com uma sentinela no final, um padrão que descobri útil para fazer rotação cíclica sem contar manualmente quantos modos existem:
enum AppMode {
MODO_RELOGIO = 0,
MODO_CLIMA,
MODO_MUSICA,
MODO_TOTAL // indica o tamanho do enum
};Com isso, avançar e retroceder entre modos vira uma operação de módulo simples:
modoAtual = (AppMode)((modoAtual + 1) % MODO_TOTAL);O ESP32 conecta em pool.ntp.org via configTime() e depois lê o tempo com getLocalTime(). É importante notar que o NTP não funciona como uma API REST, e sim um protocolo UDP assíncrono, e o SDK do ESP32 faz a sincronização.
Caso não haja conexão wifi, existe um fallback que incrementa os segundos manualmente:
void atualizaRelogio() {
struct tm timeinfo;
if (getLocalTime(&timeinfo)) {
relogio.hora = timeinfo.tm_hour;
relogio.minuto = timeinfo.tm_min;
relogio.segundo = timeinfo.tm_sec;
// ...
relogio.ntpSincronizado = true;
} else {
// fallback
static unsigned long ultimo = 0;
if (millis() - ultimo >= 1000UL) {
ultimo = millis();
if (++relogio.segundo >= 60) { relogio.segundo = 0;
if (++relogio.minuto >= 60) { relogio.minuto = 0;
if (++relogio.hora >= 24) { relogio.hora = 0;
relogio.dia++; }}}
}
}
}Um asterisco no canto da tela indica se o NTP está sincronizado.
Esse modo foi o mais interessante de implementar. A cada 5 minutos, o ESP32 faz um GET para a API do OpenWeatherMap, usa o JSON com a biblioteca ArduinoJson e atualiza a struct de estado.
void buscaClima() {
if (clima.dadosValidos &&
millis() - clima.ultimaBusca < INTERVALO_CLIMA) return;
HTTPClient http;
char url[256];
snprintf(url, sizeof(url),
"http://api.openweathermap.org/data/2.5/weather"
"?q=%s,%s&appid=%s&units=metric&lang=pt_br",
OWM_CIDADE, OWM_PAIS, OWM_API_KEY);
http.begin(url);
if (http.GET() == 200) {
StaticJsonDocument<1024> doc;
deserializeJson(doc, http.getString());
clima.temperatura = (int)roundf((float)doc["main"]["temp"]);
clima.humidade = doc["main"]["humidity"];
clima.vento = (float)doc["wind"]["speed"] * 3.6f;
// ...
clima.dadosValidos = true;
}
http.end();
}Como o heap do ESP32 é limitado e fragmentação pode causar crashes difíceis de debugar, o StaticJsonDocument<1024> aloca o buffer na stack.
Os ícones de clima (sol, nuvem, chuva, neve, tempestade) são bitmaps 8×8 armazenados na flash.
const uint8_t ICO_SOL[] PROGMEM = {
0b00010000, 0b01010100, 0b00111000, 0b11111110,
0b00111000, 0b01010100, 0b00010000, 0b00000000
};Limitação do simulador: Para a simulação funcionar foi necessário substituir a função por dados estáticos, pois o simulador não realiza chamadas HTTP reais. O http.GET() nunca retorna 200, então o modo clima ficava eternamente em "Buscando dados...".
Este modo serámelhorado futuramente, pois para funcionar o sistema precisaria de um microfone ADC e uma FFT.
Logo, o que o modo atual faz é simular visualmente o comportamento de um espectro com três técnicas:
1. Envelope de banda: frequências médias naturalmente aparecem mais altas que extremos em música. Modelei isso com uma curva senoidal:
float norm = (float)i / (NUM_BARRAS - 1);
envelope[i] = 0.3f + 0.7f * sinf(norm * 3.14159f);2. Suavização exponencial: sem suavização, as barras saltam sem parar e parecem ruído. Suevizando, o movimento fica mais fluido:
espectro[i] = (espectroAnt[i] * 6 + alvo * 4) / 10;3. Marcadores de pico: um pico que sobe instantaneamente junto com a barra, mas desce mais devagar:
if (espectro[i] > pico[i]) {
pico[i] = espectro[i];
tempoPico[i] = millis();
} else if (millis() - tempoPico[i] > PICO_HOLD_MS) {
if (pico[i] > 0) pico[i]--;
}Cada botão conecta um GPIO ao GND. Com INPUT_PULLUP, o pino lê HIGH em repouso e LOW quando pressionado.
O debounce é feito por timestamp, não por delay. Motivo da escolha: um delay(200) trava o programa inteiro por 200ms. Com timestamp, o loop continua rodando normalmente e simplesmente ignora novas detecções dentro da janela de tempo:
void verificaBotoes() {
if (millis() - ultimoBotao < DEBOUNCE_MS) return;
if (proximoApertado()) {
modoAtual = (AppMode)((modoAtual + 1) % MODO_TOTAL);
salvaConfiguracoes();
ultimoBotao = millis();
}
}O ESP32 tem uma área de flash reservada para o NVS (Non-Volatile Storage), acessível pela biblioteca Preferences. Usei para salvar o modo atual, para que ele seja restaurado após um reset:
void salvaConfiguracoes() {
prefs.begin("widget", false);
prefs.putInt("modo", (int)modoAtual);
prefs.end();
}O loop segue uma ordem fixa: input > update > render. A renderização é limitada a 10 fps com um timestamp, para não desperdiçar ciclos com frames iguais no display:
void loop() {
verificaBotoes();
atualizaRelogio();
if (modoAtual == MODO_CLIMA) buscaClima();
if (modoAtual == MODO_MUSICA) atualizaEspectro();
if (millis() - ultimaRenderizacao >= INTERVALO_RENDER) {
ultimaRenderizacao = millis();
renderFrame();
}
}- Testar o código wm um harware real para verificar o funcionamento fora das limitações do simulador.
- Adicionar um microfone ADC e uma FFT ao sistema para funcionamento do modo música.
O código completo, o diagrama e o README com as instruções de montagem estão no repositório do GitHub.