/* * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD * * SPDX-License-Identifier: MIT */ #include "stackchan_display.h" #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace stackchan; using namespace stackchan::avatar; #define TAG "StackChanAvatarDisplay" LV_FONT_DECLARE(BUILTIN_TEXT_FONT); LV_FONT_DECLARE(BUILTIN_ICON_FONT); LV_FONT_DECLARE(font_awesome_30_4); // Have to register themes, so the asset apply can update the text font void StackChanAvatarDisplay::InitializeLcdThemes() { auto text_font = std::make_shared(&BUILTIN_TEXT_FONT); auto icon_font = std::make_shared(&BUILTIN_ICON_FONT); auto large_icon_font = std::make_shared(&font_awesome_30_4); // light theme auto light_theme = new LvglTheme("light"); light_theme->set_background_color(lv_color_hex(0xFFFFFF)); // rgb(255, 255, 255) light_theme->set_text_color(lv_color_hex(0x000000)); // rgb(0, 0, 0) light_theme->set_chat_background_color(lv_color_hex(0xE0E0E0)); // rgb(224, 224, 224) light_theme->set_user_bubble_color(lv_color_hex(0x00FF00)); // rgb(0, 128, 0) light_theme->set_assistant_bubble_color(lv_color_hex(0xDDDDDD)); // rgb(221, 221, 221) light_theme->set_system_bubble_color(lv_color_hex(0xFFFFFF)); // rgb(255, 255, 255) light_theme->set_system_text_color(lv_color_hex(0x000000)); // rgb(0, 0, 0) light_theme->set_border_color(lv_color_hex(0x000000)); // rgb(0, 0, 0) light_theme->set_low_battery_color(lv_color_hex(0x000000)); // rgb(0, 0, 0) light_theme->set_text_font(text_font); light_theme->set_icon_font(icon_font); light_theme->set_large_icon_font(large_icon_font); // dark theme auto dark_theme = new LvglTheme("dark"); dark_theme->set_background_color(lv_color_hex(0x000000)); // rgb(0, 0, 0) dark_theme->set_text_color(lv_color_hex(0xFFFFFF)); // rgb(255, 255, 255) dark_theme->set_chat_background_color(lv_color_hex(0x1F1F1F)); // rgb(31, 31, 31) dark_theme->set_user_bubble_color(lv_color_hex(0x00FF00)); // rgb(0, 128, 0) dark_theme->set_assistant_bubble_color(lv_color_hex(0x222222)); // rgb(34, 34, 34) dark_theme->set_system_bubble_color(lv_color_hex(0x000000)); // rgb(0, 0, 0) dark_theme->set_system_text_color(lv_color_hex(0xFFFFFF)); // rgb(255, 255, 255) dark_theme->set_border_color(lv_color_hex(0xFFFFFF)); // rgb(255, 255, 255) dark_theme->set_low_battery_color(lv_color_hex(0xFF0000)); // rgb(255, 0, 0) dark_theme->set_text_font(text_font); dark_theme->set_icon_font(icon_font); dark_theme->set_large_icon_font(large_icon_font); auto& theme_manager = LvglThemeManager::GetInstance(); theme_manager.RegisterTheme("light", light_theme); theme_manager.RegisterTheme("dark", dark_theme); } StackChanAvatarDisplay::StackChanAvatarDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, int width, int height, int offset_x, int offset_y, bool mirror_x, bool mirror_y, bool swap_xy) : LvglDisplay(), panel_io_(panel_io), panel_(panel) { width_ = width; height_ = height; // Initialize LCD themes InitializeLcdThemes(); // Load theme from settings Settings settings("display", false); std::string theme_name = settings.GetString("theme", "light"); current_theme_ = LvglThemeManager::GetInstance().GetTheme(theme_name); // Draw white screen std::vector buffer(width_, 0xFFFF); for (int y = 0; y < height_; y++) { esp_lcd_panel_draw_bitmap(panel_, 0, y, width_, y + 1, buffer.data()); } // Set the display to on ESP_LOGI(TAG, "Turning display on"); { esp_err_t __err = esp_lcd_panel_disp_on_off(panel_, true); if (__err == ESP_ERR_NOT_SUPPORTED) { ESP_LOGW(TAG, "Panel does not support disp_on_off; assuming ON"); } else { ESP_ERROR_CHECK(__err); } } ESP_LOGI(TAG, "Initialize LVGL library"); lv_init(); #if CONFIG_SPIRAM // lv image cache, currently only PNG is supported size_t psram_size_mb = esp_psram_get_size() / 1024 / 1024; if (psram_size_mb >= 8) { lv_image_cache_resize(2 * 1024 * 1024, true); ESP_LOGI(TAG, "Use 2MB of PSRAM for image cache"); } else if (psram_size_mb >= 2) { lv_image_cache_resize(512 * 1024, true); ESP_LOGI(TAG, "Use 512KB of PSRAM for image cache"); } #endif ESP_LOGI(TAG, "Initialize LVGL port"); lvgl_port_cfg_t port_cfg = ESP_LVGL_PORT_INIT_CONFIG(); port_cfg.task_priority = 20; #if CONFIG_SOC_CPU_CORES_NUM > 1 port_cfg.task_affinity = 1; #endif lvgl_port_init(&port_cfg); ESP_LOGI(TAG, "Adding LCD display"); const lvgl_port_display_cfg_t display_cfg = { .io_handle = panel_io_, .panel_handle = panel_, .control_handle = nullptr, .buffer_size = static_cast(width_ * 20), .double_buffer = false, .trans_size = 0, .hres = static_cast(width_), .vres = static_cast(height_), .monochrome = false, .rotation = { .swap_xy = swap_xy, .mirror_x = mirror_x, .mirror_y = mirror_y, }, .color_format = LV_COLOR_FORMAT_RGB565, .flags = { .buff_dma = 1, .buff_spiram = 0, .sw_rotate = 0, .swap_bytes = 1, .full_refresh = 0, .direct_mode = 0, }, }; display_ = lvgl_port_add_disp(&display_cfg); if (display_ == nullptr) { ESP_LOGE(TAG, "Failed to add display"); return; } if (offset_x != 0 || offset_y != 0) { lv_display_set_offset(display_, offset_x, offset_y); } // Create a timer to hide the preview image esp_timer_create_args_t preview_timer_args = { .callback = [](void* arg) { StackChanAvatarDisplay* display = static_cast(arg); display->SetPreviewImage(nullptr); }, .arg = this, .dispatch_method = ESP_TIMER_TASK, .name = "preview_timer", .skip_unhandled_events = false, }; esp_timer_create(&preview_timer_args, &preview_timer_); // Create boot logo label if not warm boot if (GetHAL().getWarmRebootTarget() < 0) { ESP_LOGI(TAG, "Create boot logo label"); Lock(); { uitk::lvgl_cpp::ScreenActive screen; screen.setBgColor(lv_color_hex(0x000000)); } GetHAL().bootLogo = std::make_unique(); Unlock(); } // Robot will be created later in SetupXiaoZhiUI() } StackChanAvatarDisplay::~StackChanAvatarDisplay() { ESP_LOGI(TAG, "Destroying StackChanAvatarDisplay"); if (preview_timer_ != nullptr) { esp_timer_stop(preview_timer_); esp_timer_delete(preview_timer_); } if (preview_image_ != nullptr) { lv_obj_del(preview_image_); } auto& stackchan = GetStackChan(); if (stackchan.hasAvatar()) { stackchan.resetAvatar(); } } bool StackChanAvatarDisplay::Lock(int timeout_ms) { return lvgl_port_lock(timeout_ms); } void StackChanAvatarDisplay::Unlock() { lvgl_port_unlock(); } lv_disp_t* StackChanAvatarDisplay::GetLvglDisplay() { return display_; } #include void StackChanAvatarDisplay::SetupUI() { // Prevent duplicate calls - if already called, return early if (setup_ui_called_) { ESP_LOGW(TAG, "SetupUI() called multiple times, skipping duplicate call"); return; } Display::SetupUI(); // Mark SetupUI as called auto& stackchan = GetStackChan(); if (stackchan.hasAvatar()) { ESP_LOGW(TAG, "Avatar already created"); return; } DisplayLockGuard lock(this); ESP_LOGI(TAG, "Creating Stack-chan Avatar..."); auto avatar = std::make_unique(); avatar->init(lv_screen_active()); avatar->getPanel()->onClick().connect([]() { if (hal_bridge::is_xiaozhi_ready()) { hal_bridge::toggle_xiaozhi_chat_state(); } }); stackchan.attachAvatar(std::move(avatar)); stackchan.addModifier(std::make_unique()); blink_modifier_id_ = stackchan.addModifier(std::make_unique()); stackchan.addModifier(std::make_unique()); stackchan.addModifier(std::make_unique()); preview_image_ = lv_image_create(lv_screen_active()); lv_obj_set_size(preview_image_, 320, 240); lv_obj_align(preview_image_, LV_ALIGN_CENTER, 0, 0); lv_obj_add_flag(preview_image_, LV_OBJ_FLAG_HIDDEN); // GetHAL().startStackChanAutoUpdate(24); ESP_LOGI(TAG, "Avatar created and started"); } void StackChanAvatarDisplay::LvglLock() { if (!Lock(30000)) { ESP_LOGE("Display", "Failed to lock display"); } } void StackChanAvatarDisplay::LvglUnlock() { Unlock(); } void StackChanAvatarDisplay::SetEmotion(const char* emotion) { auto& stackchan = GetStackChan(); if (!stackchan.hasAvatar() || !emotion) { return; } DisplayLockGuard lock(this); // ESP_LOGE(TAG, "SetEmotion: %s", emotion); auto& avatar = stackchan.avatar(); // Map emotion string to stackchan::Emotion if (strcmp(emotion, "neutral") == 0) { avatar.setEmotion(Emotion::Neutral); } else if (strcmp(emotion, "happy") == 0) { avatar.setEmotion(Emotion::Happy); } else if (strcmp(emotion, "laughing") == 0) { avatar.setEmotion(Emotion::Happy); } else if (strcmp(emotion, "angry") == 0) { avatar.setEmotion(Emotion::Angry); } else if (strcmp(emotion, "sad") == 0) { avatar.setEmotion(Emotion::Sad); } else if (strcmp(emotion, "crying") == 0) { avatar.setEmotion(Emotion::Sad); } else if (strcmp(emotion, "sleepy") == 0) { avatar.setEmotion(Emotion::Sleepy); avatar.setSpeech("Zzz…"); is_sleeping_ = true; // avatar.mouth().setWeight(10); // Stop idle motion ESP_LOGW(TAG, "Stop idle motion"); if (idle_motion_modifier_id_ >= 0) { stackchan.removeModifier(idle_motion_modifier_id_); idle_motion_modifier_id_ = -1; stackchan.removeModifier(idle_expression_modifier_id_); idle_expression_modifier_id_ = -1; } // Return to default pose auto& motion = GetStackChan().motion(); motion.pitchServo().moveWithSpeed(0, 80); } else if (strcmp(emotion, "doubtful") == 0) { avatar.setEmotion(Emotion::Doubt); } else { ESP_LOGW(TAG, "Unknown emotion: %s, using NEUTRAL", emotion); avatar.setEmotion(Emotion::Neutral); } // Resync blink modifier base eye weights auto blink_modifier = static_cast(stackchan.getModifier(blink_modifier_id_)); if (blink_modifier) { blink_modifier->resyncEyeWeights(); } } void StackChanAvatarDisplay::SetChatMessage(const char* role, const char* content) { if (!setup_ui_called_) { ESP_LOGW(TAG, "SetChatMessage('%s', '%s') called before SetupUI() - message will be lost!", role, content); } auto& stackchan = GetStackChan(); if (!stackchan.hasAvatar()) { return; } // ESP_LOGE(TAG, "SetChatMessage: role=%s, content=%s", role ? role : "null", content ? content : "null"); DisplayLockGuard lock(this); if (strcmp(role, "system") == 0) { stackchan.avatar().setSpeech(content); } else if (strcmp(role, "assistant") == 0) { stackchan.avatar().setSpeech(content); } } void StackChanAvatarDisplay::ClearChatMessages() { auto& stackchan = GetStackChan(); if (!stackchan.hasAvatar()) { return; } DisplayLockGuard lock(this); stackchan.avatar().clearSpeech(); ESP_LOGI(TAG, "Chat messages cleared"); } void StackChanAvatarDisplay::SetPreviewImage(std::unique_ptr image) { DisplayLockGuard lock(this); if (preview_image_ == nullptr) { return; } if (image == nullptr) { esp_timer_stop(preview_timer_); lv_obj_add_flag(preview_image_, LV_OBJ_FLAG_HIDDEN); preview_image_cached_.reset(); return; } preview_image_cached_ = std::move(image); auto img_dsc = preview_image_cached_->image_dsc(); // Set image source and show preview image lv_image_set_src(preview_image_, img_dsc); if (img_dsc->header.w > 0 && img_dsc->header.h > 0) { // Scale to fit width lv_image_set_scale(preview_image_, 256 * width_ / img_dsc->header.w); } lv_obj_remove_flag(preview_image_, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(preview_image_); esp_timer_stop(preview_timer_); ESP_ERROR_CHECK(esp_timer_start_once(preview_timer_, 6000 * 1000)); } void StackChanAvatarDisplay::UpdateStatusBar(bool update_all) { } void StackChanAvatarDisplay::SetTheme(Theme* theme) { ESP_LOGI(TAG, "SetTheme: %s", theme->name().c_str()); auto& stackchan = GetStackChan(); if (!stackchan.hasAvatar()) { ESP_LOGE(TAG, "Avatar is invalid"); return; } DisplayLockGuard lock(this); auto lvgl_theme = static_cast(theme); auto text_font = lvgl_theme->text_font()->font(); stackchan.avatar().setSpeechTextFont((void*)text_font); } #include static bool _is_xiaozhi_ready = false; bool hal_bridge::is_xiaozhi_ready() { return _is_xiaozhi_ready; } void StackChanAvatarDisplay::SetStatus(const char* status) { // ESP_LOGE(TAG, "SetStatus: %s", status); auto& stackchan = GetStackChan(); if (!stackchan.hasAvatar()) { ESP_LOGE(TAG, "Avatar is invalid"); return; } auto& avatar = stackchan.avatar(); auto& motion = stackchan.motion(); DisplayLockGuard lock(this); bool is_idle = false; bool is_listening = false; if (strcmp(status, Lang::Strings::LISTENING) == 0) { if (speaking_modifier_id_ >= 0) { // Start speaking stackchan.removeModifier(speaking_modifier_id_); avatar.mouth().setWeight(0); speaking_modifier_id_ = -1; } GetHAL().setRgbColor(0, 0, 50, 0); GetHAL().refreshRgb(); } else if (strcmp(status, Lang::Strings::STANDBY) == 0) { _is_xiaozhi_ready = true; if (speaking_modifier_id_ >= 0) { // Stop speaking stackchan.removeModifier(speaking_modifier_id_); avatar.mouth().setWeight(0); speaking_modifier_id_ = -1; } is_idle = true; GetHAL().setRgbColor(0, 0, 0, 0); GetHAL().refreshRgb(); } else if (strcmp(status, Lang::Strings::SPEAKING) == 0) { if (speaking_modifier_id_ < 0) { speaking_modifier_id_ = stackchan.addModifier(std::make_unique()); } GetHAL().setRgbColor(0, 0, 0, 50); GetHAL().refreshRgb(); } else { avatar.setSpeech(status); } if (is_idle) { // Start idle motion ESP_LOGW(TAG, "Start idle motion"); if (idle_motion_modifier_id_ < 0) { idle_motion_modifier_id_ = stackchan.addModifier(std::make_unique()); idle_expression_modifier_id_ = stackchan.addModifier(std::make_unique()); } } else { // Stop idle motion ESP_LOGW(TAG, "Stop idle motion"); if (idle_motion_modifier_id_ >= 0) { stackchan.removeModifier(idle_motion_modifier_id_); idle_motion_modifier_id_ = -1; stackchan.removeModifier(idle_expression_modifier_id_); idle_expression_modifier_id_ = -1; } // if (!is_listening) { // // Return to default pose // motion.pitchServo().moveWithSpeed(200, 350); // motion.yawServo().moveWithSpeed(0, 350); // } } // Clear sleep state if (is_sleeping_) { avatar.setSpeech(""); } } void StackChanAvatarDisplay::ShowNotification(const char* notification, int duration_ms) { }