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:
- Camada de transporte: WebSocket ou SSE recebendo eventos JSON como
{"state":"thinking"}. - Camada de estado: uma máquina de estados simples com timeout — se ficar em thinking por mais de 30 s, cai em error.
- 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_initializededicado — 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
invertColorno 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.

Deixe um comentário
Entre com Google ou Facebook para comentar. Nome e e-mail vêm da sua conta — sem preencher formulário.
Se o login falhar, configure Google e Facebook em Configurações → Nextend Social Login.
Outras opções de login