나만의 자비스 만들기 3편 — 3.5인치 화면에 JARVIS를 그리다

나만의 자비스 만들기 3편 — 3.5인치 화면에 JARVIS를 그리다

LVGL로 음성 비서 UI 구현. 대기/듣기/생각/말하기 4가지 상태 애니메이션, 시계, 날씨 위젯까지
📅 2026년 5월 24일 ✍️ Bongjoo ESP32 JARVIS LVGL UI 터치디스플레이

이번 편에서 만들 것

1편에서 귀를 달고, 2편에서 입을 달았습니다. 최종편에서는 얼굴을 만듭니다. 3.5인치 터치 디스플레이에 JARVIS 스타일의 UI를 구현합니다.

LVGL UI
LVGL로 구현한 스마트 디스플레이 UI
ESPHome LVGL
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

완성된 자비스의 모습

비용 정리

항목비용
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에 가까워질 것입니다.

참고 링크

← 모든 글 보기