Display e UI do assistente ESP32: painel redondo com LVGL, rosto animado e estado

Ouvir

Carregando voz…

Display e UI do assistente ESP32: painel redondo com LVGL, rosto animado e estado

Segundo artigo da série Assistentes de IA de Bancada. Se no primeiro montamos o pipeline de inferência, agora damos presença ao assistente: um display com rosto, estados animados e UI responsive. É aqui que o projeto deixa de parecer um terminal e passa a ser um objeto de bancada.

(a) Conceito — quem desenha o quê

A regra de ouro de UI em MCU é não bloquear o loop. Separe mentalmente três coisas: o modelo de estado (idle, listening, thinking, speaking, error), o render (o que aparece na tela) e a entrada (toque, botão, eventos de rede). O host manda transições de estado por mensagens curtas; a placa interpola e anima localmente.

Arquitetura recomendada:

  1. Camada de transporte: WebSocket ou SSE recebendo eventos JSON como {"state":"thinking"}.
  2. Camada de estado: uma máquina de estados simples com timeout — se ficar em thinking por mais de 30 s, cai em error.
  3. Camada de render: LVGL com um timer de ~33 ms (30 FPS) para animações não críticas e refresh sob demanda para mudanças de estado.

Para o “rosto”, duas abordagens rendem bons resultados: olhos geométricos (dois círculos animados com blur e piscar) ou sprite sheet em flash. O primeiro é mais leve e escala melhor; o segundo dá personalidade autoral.

(b) BOM

Item Modelo Função Faixa (USD)
MCU ESP32-S3 N16R8 8 MB PSRAM para framebuffer 8–14
Display redondo GC9A01 1.28″ 240×240 SPI Rosto / HUD principal 4–7
Display TFT ST7789 1.9″ 170×320 SPI Alternativa retangular 4–6
Touch (opcional) CST816S (capacitivo I2C) Interação direta 3–5
Diffusor Acrílico leitoso 2 mm Suaviza LEDs e luz de fundo 1–3
Amp + falante MAX98357A + 4Ω 3W Voz do assistente 5–9
Fonte 5V/2A com capacitor 470µF Estabilidade 3–6

Kit típico: 30 a 50 dólares. Um display redondo dá identidade; um TFT dá mais área útil para texto e valores.

(c) Wiring do display

SPI é o caminho para painéis baratos. Mapeamento funcional no S3:

  • SPI2_HOST: SCLK=GPIO12, MOSI=GPIO11, MISO não usado = -1.
  • CS=GPIO10, DC=GPIO9, RST=GPIO8, BK=GPIO7 (backlight, PWM opcional).
  • Touch I2C: SDA=GPIO4, SCL=GPIO5, INT=GPIO6, RST compartilhado com o painel (ou GPIO3).
  • Transfira o barramento SPI ao display com spi_bus_initialize dedicado — não divida com o cartão SD se puder evitar.

Cuidados:

  • O backlight em 3V3 direto funciona, mas PWM com MOSFET/transistor permite dimmer e economiza corrente.
  • Fios > 20 cm em SPI geram artefatos; se precisar, reduza o clock para 20–40 MHz.
  • Alguns GC9A01 vêm com color inversion ativado. Se as cores parecerem negativas, ajuste invertColor no driver.
  • Framebuffer de 240×240 em RGB565 = 115.200 bytes. Isso só cabe na PSRAM — não tente no heap interno.

(d) Código — init, buffer e animação

Com o framework Arduino e Arduino_GFX / LovyanGFX, o init fica enxuto. Deixe o LVGL cuidar do buffer e do flush:

#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include <esp_heap_caps.h>

Arduino_DataBus *bus = new Arduino_ESP32SPI(9 /*DC*/, 10 /*CS*/, 12 /*SCK*/, 11 /*MOSI*/);
Arduino_GFX *gfx = new Arduino_GC9A01(bus, 8 /*RST*/, 0 /*rotation*/);

static lv_disp_draw_buf_t draw_buf;
static lv_color_t *buf1;
static lv_disp_drv_t disp_drv;

void gfx_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *px) {
  gfx->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)px,
                        area->x2 - area->x1 + 1,
                        area->y2 - area->y1 + 1);
  lv_disp_flush_ready(drv);
}

void display_init() {
  gfx->begin(40000000);            // 40 MHz
  gfx->fillScreen(BLACK);

  // buffer de UMA linha dupla em PSRAM
  uint32_t px = 240 * 20;
  buf1 = (lv_color_t *)heap_caps_malloc(px * sizeof(lv_color_t), MALLOC_CAP_SPIRAM);
  lv_disp_draw_buf_init(&draw_buf, buf1, NULL, px);

  lv_disp_drv_init(&disp_drv);
  disp_drv.hor_res = 240;
  disp_drv.ver_res = 240;
  disp_drv.flush_cb = gfx_flush;
  disp_drv.draw_buf = &draw_buf;
  lv_disp_drv_register(&disp_drv);
}

Agora a máquina de estado e uma animação simples — olhos que respiram, piscam e mudam de cor conforme o estado:

lv_obj_t *eyeL, *eyeR;
lv_timer_t *blink_timer;

void set_state(const char* s) {
  if (strcmp(s, "listening") == 0) {
    lv_obj_set_style_bg_color(eyeL, lv_color_hex(0x00E5FF), 0);
    lv_obj_set_style_bg_color(eyeR, lv_color_hex(0x00E5FF), 0);
  } else if (strcmp(s, "thinking") == 0) {
    lv_obj_set_style_bg_color(eyeL, lv_color_hex(0xFFC400), 0);
    lv_obj_set_style_bg_color(eyeR, lv_color_hex(0xFFC400), 0);
  } else if (strcmp(s, "speaking") == 0) {
    lv_obj_set_style_bg_color(eyeL, lv_color_hex(0x00FF88), 0);
    lv_obj_set_style_bg_color(eyeR, lv_color_hex(0x00FF88), 0);
  } else { // idle
    lv_obj_set_style_bg_color(eyeL, lv_color_white(), 0);
    lv_obj_set_style_bg_color(eyeR, lv_color_white(), 0);
  }
}

void blink_cb(lv_timer_t *t) {
  lv_obj_set_height(eyeL, 12);
  lv_obj_set_height(eyeR, 12);
  lv_timer_t *open = lv_timer_create([](lv_timer_t* o){
    lv_obj_set_height(eyeL, 64);
    lv_obj_set_height(eyeR, 64);
    lv_timer_del(o);
  }, 120, NULL);
  lv_timer_set_repeat_count(open, 1);
}

void loop() {
  lv_timer_handler();            // nunca bloqueie aqui
  delay(5);
}

No setup(), chame lv_init(), display_init() e crie os objetos. Registre blink_cb com lv_timer_create(..., 3000 + rand() % 2000, NULL) para piscadas naturais. O host só precisa emitir set_state("thinking") via WebSocket — o resto é local.

(e) Troubleshooting — frame rate, PSRAM e partições

Frame rate baixo: se a tela “arrasta”, quase sempre é flush síncrono com blit de framebuffer inteiro. Reduza o buffer (linha dupla em vez de tela cheia) e anime apenas os objetos que mudam. 30 FPS é suficiente para um rosto; 60 FPS só vale em transições curtas.

Cores erradas: invertColor ligado no driver, ou byte-swap do RGB565. Teste com um padrão de barras antes de culpar a UI.

Tearing: sem double buffer e sem sincronismo, você vê rasgo. Com PSRAM, use dois buffers; em telas pequenas, o custo é aceitável.

PSRAM: confirme que ela está ativa no boot (esp_psram_get_size()). Drivers de display alocando no heap interno estouram rápido. Framebuffer grande → MALLOC_CAP_SPIRAM.

Partições de flash: LVGL com sprites e fontes come espaço. O layout default de 16 MB precisa de ajuste — crie uma tabela de partição com nvs, ota_0, ota_1, spiffs/littlefs para assets. Deixe OTA de pelo menos 3 MB se pretende atualizar o firmware por rede. Assets (fontes, imagens) vão em LittleFS, não em variáveis globais.

O que travar na placa vs. host: render, animação, piscadas, debounce e timeout ficam na placa. Fontes grandes, geração de subtítulos e lógica de conversa ficam no host. Nunca envie bitmaps de UI pelo Wi-Fi — envie estado.

(f) Aplicações reais

  • Rosto de bancada: olhos que reagem ao estado da inferência tornam o tempo de espera perceptível e menos frustrante.
  • Painel de status: além do rosto, um canto mostra latência, modelo ativo e uso de tokens — útil para depurar o pipeline do artigo anterior.
  • Dashboard embarcado: com TFT retangular, exiba grafos do host (via API) ou métricas do laboratório.
  • Produto de mesa: base de UI reutilizável para assistentes com ESP32-S3 + LVGL, com licença MIT facilitando fork.
  • Integração com firmware open-source: o xiaozhi-esp32 já traz o esqueleto de UI e pode servir de ponto de partida para a série.

Resumo: display e UI são o que transforma um módulo em assistente. A arquitetura é a mesma do Post 1 — estado no host, animação na placa — o que mantém o firmware pequeno, o OTA viável e a experiência fluida.

Não some depois da matéria

Radar do hub: Núcleo Hits, Além do Oculto, Bobinho. Sem spray, sem lista comprada.

Radar do hub

Um e-mail quando sair matéria que importa — sem spray de newsletter genérica.