이번 편에서 만들 것
1편에서 귀를 달고, 2편에서 입을 달았습니다. 최종편에서는 얼굴을 만듭니다. 3.5인치 터치 디스플레이에 JARVIS 스타일의 UI를 구현합니다.
LVGL로 구현한 스마트 디스플레이 UI
ESPHome + LVGL 조합으로 Home Assistant 연동
UI 상태 4가지
음성 비서의 화면은 4가지 상태를 전환합니다:
| 상태 | 화면 | 설명 |
|---|---|---|
| IDLE | 시계 + 날씨 위젯 | 대기 상태. 현재 시간, 날씨, 요약 정보 표시 |
| LISTENING | 파란색 Arc 애니메이션 | Wake Word 감지 후 사용자 말 기다림 |
| THINKING | 회전하는 점 애니메이션 | STT/LLM 처리 중 |
| SPEAKING | 음성 파형 + 텍스트 | TTS 음성 재생 중, LLM 응답 텍스트 표시 |
1. LVGL 환경 설정
LVGL(Light and Versatile Graphics Library)은 임베디드 디바이스용 GUI 라이브러리입니다. ESP32-S3에서 완벽하게 동작합니다.
# ESP-IDF + LVGL 프로젝트 생성
cd ~/esp
idf.py create-project jarvis-ui
cd jarvis-ui
# LVGL 컴포넌트 추가
idf.py add-dependency "lvgl/lvgl>=9.0"
idf.py add-dependency "espressif/esp_lvgl_port"
# 디스플레이 드라이버 (JC3248W535C: ST7796S)
idf.py add-dependency "espressif/esp_lcd_st7796s"
2. 디스플레이 초기화
JC3248W535C 모듈의 3.5인치 TFT는 ST7796S 드라이버를 사용합니다. 320x480 해상도, SPI 인터페이스입니다.
#include "esp_lcd_st7796s.h"
#include "esp_lvgl_port.h"
#include "lvgl.h"
// SPI 핀 설정 (JC3248W535C 기준)
#define LCD_MOSI GPIO11
#define LCD_MISO GPIO13
#define LCD_SCLK GPIO12
#define LCD_CS GPIO10
#define LCD_DC GPIO14
#define LCD_RST GPIO21
#define LCD_BL GPIO2
#define LCD_H_RES 320
#define LCD_V_RES 480
void init_display() {
// SPI 버스 초기화
spi_bus_config_t bus_cfg = {
.mosi_io_num = LCD_MOSI,
.miso_io_num = LCD_MISO,
.sclk_io_num = LCD_SCLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = LCD_H_RES * LCD_V_RES * 2,
};
spi_bus_initialize(SPI2_HOST, &bus_cfg, SPI_DMA_CH_AUTO);
// LCD 드라이버 초기화
esp_lcd_panel_handle_t panel_handle;
esp_lcd_panel_dev_config_t panel_config = {
.reset_gpio_num = LCD_RST,
.color_space = ESP_LCD_COLOR_SPACE_RGB565,
.bits_per_pixel = 16,
};
esp_lcd_new_panel_st7796s_spi(
&(esp_lcd_panel_io_spi_config_t){
.cs_gpio_num = LCD_CS,
.dc_gpio_num = LCD_DC,
.pclk_hz = 20 * 1000 * 1000,
.spi_mode = 0,
.trans_queue_depth = 10,
},
&panel_config,
&panel_handle
);
// 백라이트 켜기
gpio_set_direction(LCD_BL, GPIO_MODE_OUTPUT);
gpio_set_level(LCD_BL, 1);
// LVGL 초기화
const lvgl_port_cfg_t lvgl_cfg = {
.task_priority = 5,
.task_stack = 4096,
.task_affinity = -1,
.task_max_sleep_ms = 500,
.timer_period_ms = 5,
};
lvgl_port_init(&lvgl_cfg);
// LVGL 디스플레이 추가
lv_display_t *disp = lvgl_port_add_disp(&panel_handle);
lv_disp_set_rotation(disp, LV_DISP_ROT_90); // 세로 모드
}
3. IDLE 화면: 시계 + 날씨 위젯
대기 상태에서는 시계와 날씨 정보를 표시합니다. JARVIS 스타일의 HUD(Head-Up Display) 디자인을 목표로 합니다.
void create_idle_screen() {
lv_obj_t *scr = lv_screen_active();
lv_obj_set_style_bg_color(scr,
lv_color_hex(0x0a0a1a), 0); // 어두운 배경
// 시계 레이블
clock_label = lv_label_create(scr);
lv_obj_set_style_text_font(clock_label,
&lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(clock_label,
lv_color_hex(0x00bfff), 0); // JARVIS 블루
lv_obj_align(clock_label,
LV_ALIGN_CENTER, 0, -60);
lv_label_set_text(clock_label, "12:34");
// 날씨 위젯
weather_label = lv_label_create(scr);
lv_obj_set_style_text_font(weather_label,
&lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(weather_label,
lv_color_hex(0x87ceeb), 0);
lv_obj_align(weather_label,
LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(weather_label,
"22C | 맑음 | 습도 45%");
// 상태 표시
status_label = lv_label_create(scr);
lv_obj_set_style_text_font(status_label,
&lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(status_label,
lv_color_hex(0x555555), 0);
lv_obj_align(status_label,
LV_ALIGN_BOTTOM_MID, 0, -30);
lv_label_set_text(status_label,
"Hey Jarvis 대기 중...");
}
4. LISTENING: Arc 애니메이션
Wake Word가 감지되면 파란색 원형 애니메이션으로 사용자에게 "듣고 있다"는 것을 알려줍니다.
lv_obj_t *arc;
lv_obj_t *arc_label;
void create_listening_ui() {
// 기존 위젯 숨기기
lv_obj_add_flag(clock_label, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(weather_label, LV_OBJ_FLAG_HIDDEN);
// Arc (원형 게이지) 생성
arc = lv_arc_create(lv_screen_active());
lv_obj_set_size(arc, 200, 200);
lv_obj_center(arc);
lv_arc_set_range(arc, 0, 100);
lv_arc_set_value(arc, 0);
lv_obj_remove_style(arc, NULL,
LV_PART_KNOB); // 노브 제거
lv_obj_set_style_arc_color(arc,
lv_color_hex(0x00bfff), LV_PART_INDICATOR);
lv_obj_set_style_arc_width(arc, 8,
LV_PART_INDICATOR);
lv_obj_set_style_bg_arc_color(arc,
lv_color_hex(0x1a1a3a), LV_PART_MAIN);
lv_obj_set_style_bg_arc_width(arc, 8,
LV_PART_MAIN);
// "듣고 있어요" 텍스트
arc_label = lv_label_create(lv_screen_active());
lv_obj_set_style_text_font(arc_label,
&lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(arc_label,
lv_color_hex(0x00bfff), 0);
lv_obj_align(arc_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(arc_label, "듣고 있어요");
// Arc 회전 애니메이션 시작
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, arc);
lv_anim_set_exec_cb(&a, arc_anim_cb);
lv_anim_set_values(&a, 0, 100);
lv_anim_set_time(&a, 2000);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&a);
}
void arc_anim_cb(void *var, int32_t v) {
lv_arc_set_value((lv_obj_t *)var, v);
}
5. THINKING: 처리 중 애니메이션
void create_thinking_ui() {
// Arc 숨기고 로딩 표시
lv_obj_add_flag(arc, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(arc_label, LV_OBJ_FLAG_HIDDEN);
// 로딩 레이블
lv_obj_t *spinner = lv_spinner_create(
lv_screen_active(), 1000, 60);
lv_obj_set_size(spinner, 80, 80);
lv_obj_center(spinner);
lv_obj_set_style_arc_color(spinner,
lv_color_hex(0xffa500), LV_PART_INDICATOR);
lv_obj_t *label = lv_label_create(lv_screen_active());
lv_obj_set_style_text_color(label,
lv_color_hex(0xffa500), 0);
lv_obj_align(label, LV_ALIGN_CENTER, 0, 60);
lv_label_set_text(label, "생각 중...");
}
6. SPEAKING: 응답 표시
void create_speaking_ui(const char *text) {
// 이전 UI 정리 (spinner 등 제거)
// 응답 텍스트 표시
lv_obj_t *response = lv_label_create(
lv_screen_active());
lv_obj_set_style_text_font(response,
&lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(response,
lv_color_hex(0x00ff88), 0);
lv_obj_set_width(response, 280);
lv_obj_align(response, LV_ALIGN_CENTER, 0, 20);
lv_label_set_text(response, text);
// 말하는 동안 작은 파형 표시
lv_obj_t *bar = lv_bar_create(lv_screen_active());
lv_obj_set_size(bar, 200, 8);
lv_obj_align(bar, LV_ALIGN_CENTER, 0, -40);
lv_bar_set_value(bar, 50, LV_ANIM_ON);
// 스피커 아이콘 (이모지 대신 텍스트)
lv_obj_t *icon = lv_label_create(
lv_screen_active());
lv_obj_set_style_text_font(icon,
&lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(icon,
lv_color_hex(0x00ff88), 0);
lv_obj_align(icon, LV_ALIGN_CENTER, 0, -80);
lv_label_set_text(icon, "JARVIS");
}
7. 터치 입력 연결
JC3248W535C는 FT6X36 터치 컨트롤러를 사용합니다. ESP-IDF + LVGL 포트로 간단히 연결됩니다.
#include "esp_lcd_touch.h"
#include "esp_lcd_touch_ft5x06.h"
void init_touch() {
// I2C 터치 컨트롤러 초기화
esp_lcd_touch_handle_t tp;
esp_lcd_touch_ft5x06_config_t tp_cfg = {
.x_max = LCD_V_RES, // 회전 후
.y_max = LCD_H_RES,
.rst_gpio_num = -1,
.int_gpio_num = GPIO3,
};
esp_lcd_touch_new_i2c_ft5x06(
&tp_cfg, &tp);
// LVGL에 터치 입력 등록
lvgl_port_add_touch(tp);
// 터치 콜백 (수동 Wake Word)
lv_obj_add_event_cb(lv_screen_active(),
touch_event_cb, LV_EVENT_CLICKED, NULL);
}
// 화면 터치로 수동으로 음성 입력 시작
void touch_event_cb(lv_event_t *e) {
if (current_state == IDLE) {
set_state(LISTENING); // 터치로 바로 녹음
}
}
8. 상태 머신 전체 구조
typedef enum {
STATE_IDLE,
STATE_LISTENING,
STATE_THINKING,
STATE_SPEAKING
} JarvisState;
volatile JarvisState current_state = STATE_IDLE;
void set_state(JarvisState new_state) {
current_state = new_state;
// 이전 화면 정리
lv_obj_clean(lv_screen_active());
switch (new_state) {
case STATE_IDLE:
create_idle_screen();
break;
case STATE_LISTENING:
create_listening_ui();
start_recording();
break;
case STATE_THINKING:
create_thinking_ui();
send_to_server(); // 2편의 API 호출
break;
case STATE_SPEAKING:
create_speaking_ui(last_response);
play_audio();
break;
}
}
// WiFi 응답 콜백 (2편 서버에서 호출)
void on_server_response(const char *text,
uint8_t *audio, size_t len) {
last_response = text;
set_state(STATE_SPEAKING);
// 오디오 재생 완료 후 대기로 복귀
on_audio_finished = []() {
set_state(STATE_IDLE);
};
}
9. 날씨 + 시간 업데이트
IDLE 화면에서 1분마다 시간을, 10분마다 날씨를 업데이트합니다.
// LVGL 타이머로 주기 업데이트
void setup_timers() {
// 시계: 1초마다
lv_timer_create(clock_timer_cb, 1000, NULL);
// 날씨: 10분마다
lv_timer_create(weather_timer_cb, 600000, NULL);
}
void clock_timer_cb(lv_timer_t *t) {
time_t now;
time(&now);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
char buf[16];
snprintf(buf, sizeof(buf), "%02d:%02d",
timeinfo.tm_hour, timeinfo.tm_min);
lv_label_set_text(clock_label, buf);
}
void weather_timer_cb(lv_timer_t *t) {
// OpenWeatherMap API 호출
esp_http_client_config_t cfg = {
.url = "http://api.openweathermap.org/"
"data/2.5/weather?q=Seoul&"
"appid=YOUR_KEY&units=metric&lang=kr"
};
// 파싱 후 weather_label 업데이트
}
10. 빌드 및 플래싱
# 전체 빌드
cd ~/esp/jarvis-ui
idf.py set-target esp32s3
idf.py menuconfig # PSRAM 활성화 필수!
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor
Menuconfig 필수 설정
# Component config → ESP PSRAM
# [*] Enable PSRAM
# PSRAM type: Octal PSRAM
# Component config → LVGL configuration
# Font: Montserrat (다양한 크기)
# Color depth: 16-bit (RGB565)
# Animation: Enable
완성된 자비스의 모습
- 대기 모드: 어두운 배경에 시계 + 날씨 위젯. JARVIS 블루(#00bfff) 테마
- "Hey Jarvis!" 호출 시: 파란색 Arc 회전 애니메이션 + "듣고 있어요"
- 질문 처리 중: 오렌지색 스피너 + "생각 중..."
- 답변 재생: 초록색 텍스트로 응답 표시 + "JARVIS" 라벨
- 화면 터치: 수동으로 음성 입력 모드 진입 (Wake Word 없이)
비용 정리
| 항목 | 비용 |
|---|---|
| ESP32-S3 JC3248W535C (3.5인치 터치) | ₩27,450 |
| INMP441 마이크 | ₩3,000 |
| MAX98357A 스피커 앰프 + 스피커 | ₩5,500 |
| 점퍼 와이어 | ₩2,000 |
| OpenAI API (월 추정, 가벼운 사용) | ~$5~10/월 |
| 하드웨어 총비용 | ₩38,000 |
시리즈 요약
| 편 | 내용 | 핵심 기술 |
|---|---|---|
| 1편 | 귀를 달자 | ESP-SKAINET, WakeNet9, I2S, VAD |
| 2편 | 귀에서 입까지 | FastAPI, Whisper, GPT-4o, TTS |
| 3편 | 얼굴을 그리다 | LVGL 9, ST7796S, FT6X36, 상태머신 |
이것으로 "나만의 자비스 만들기" 시리즈를 마칩니다. 총비용 ₩38,000 + 월 $5~10으로 아이언맨의 자비스를 손에 넣을 수 있습니다. ESP32-S3의 가능성은 무궁무진합니다. 여기에 스마트홈 제어, 날씨 알림, 음악 재생 등을 추가하면 진짜 JARVIS에 가까워질 것입니다.
참고 링크
- LVGL 9.0 공식 문서
- ESP LCD 드라이버 — 다양한 패널 지원
- ESPHome LVGL Cookbook — Home Assistant 연동 예제
- JC3248W535 에너지 대시보드 — 동일 모듈 활용 예제