Files
StackChan/firmware/main/hal/board/stackchan_display.cc
T
2026-04-20 16:27:36 +08:00

531 lines
16 KiB
C++

/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
#include "stackchan_display.h"
#include <esp_log.h>
#include <esp_err.h>
#include <esp_lvgl_port.h>
#include <esp_psram.h>
#include <vector>
#include <cstring>
#include <src/misc/cache/lv_cache.h>
#include <settings.h>
#include <lvgl.h>
#include <lvgl_theme.h>
#include <stackchan/stackchan.h>
#include <assets/lang_config.h>
#include <hal/hal.h>
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<LvglBuiltInFont>(&BUILTIN_TEXT_FONT);
auto icon_font = std::make_shared<LvglBuiltInFont>(&BUILTIN_ICON_FONT);
auto large_icon_font = std::make_shared<LvglBuiltInFont>(&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<uint16_t> 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<uint32_t>(width_ * 20),
.double_buffer = false,
.trans_size = 0,
.hres = static_cast<uint32_t>(width_),
.vres = static_cast<uint32_t>(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<StackChanAvatarDisplay*>(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<BootLogo>();
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 <hal/board/hal_bridge.h>
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<DefaultAvatar>();
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<BreathModifier>());
blink_modifier_id_ = stackchan.addModifier(std::make_unique<BlinkModifier>());
stackchan.addModifier(std::make_unique<HeadPetModifier>());
stackchan.addModifier(std::make_unique<ImuEventModifier>());
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<BlinkModifier*>(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<LvglImage> 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<LvglTheme*>(theme);
auto text_font = lvgl_theme->text_font()->font();
stackchan.avatar().setSpeechTextFont((void*)text_font);
}
#include <hal/board/hal_bridge.h>
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<SpeakingModifier>());
}
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<IdleMotionModifier>());
idle_expression_modifier_id_ = stackchan.addModifier(std::make_unique<IdleExpressionModifier>());
}
} 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)
{
}