第 8 章 显示与指示:main/display/ 与 main/led/
总计 3,426 行(display 2,613 行 + led 813 行)。这两个目录都用同一套思路:抽象基类定义
OnStateChanged()/SetXxx()接口,再用多种子类对接不同的硬件。本章把所有派生类摊开讲。
8.1 整体结构
main/display/
├── display.{h,cc} ★ Display 抽象基类(无操作的 NoDisplay 也在里面)
│ 定义 SetStatus/SetEmotion/SetChatMessage/SetTheme...
│
├── lcd_display.{h,cc} ★ LCD/IPS 屏(SPI/RGB/MIPI 三种总线,LVGL)
│ SpiLcdDisplay / RgbLcdDisplay / MipiLcdDisplay
│
├── oled_display.{h,cc} ★ 黑白单色屏(128×32 / 128×64,LVGL monochrome)
│
├── emote_display.{h,cc} ★ 表情动画专用屏(不走 LVGL,自己渲染管线)
│
└── lvgl_display/ [子目录]
LvglDisplay 通用 LVGL 基类、字体、主题、GIF 控制器等
main/led/
├── led.h ★ Led 抽象(OnStateChanged 一个纯虚函数 + NoLed)
├── single_led.{h,cc} ★ 单粒 WS2812B(最常见,支持 RGB + 闪烁/常亮)
├── circular_strip.{h,cc} ★ WS2812B 环形灯带(多粒,支持 Scroll/Breathe/FadeOut)
└── gpio_led.{h,cc} ★ 普通 GPIO + PWM 单色 LED(LEDC 外设 + 硬件淡入淡出)
8.2 Display 抽象基类
8.2.1 接口
class Display {
public:
virtual void SetStatus(const char* status);
virtual void ShowNotification(const char* notification, int duration_ms = 3000);
virtual void ShowNotification(const std::string ¬ification, int duration_ms = 3000);
virtual void SetEmotion(const char* emotion);
virtual void SetChatMessage(const char* role, const char* content);
virtual void SetTheme(Theme* theme);
virtual Theme* GetTheme() { return current_theme_; }
virtual void UpdateStatusBar(bool update_all = false);
virtual void SetPowerSaveMode(bool on);
inline int width() const { return width_; }
inline int height() const { return height_; }
protected:
int width_ = 0;
int height_ = 0;
Theme* current_theme_ = nullptr;
friend class DisplayLockGuard;
virtual bool Lock(int timeout_ms = 0) = 0; // 纯虚
virtual void Unlock() = 0; // 纯虚
};
要点:
- 全部业务接口(SetStatus / SetEmotion / SetChatMessage / ShowNotification / SetTheme / UpdateStatusBar / SetPowerSaveMode)都有默认空实现——基类做日志就行,子类按需重写。
Lock/Unlock是纯虚——LVGL/EmoteEngine 都各自有自己的锁机制,子类必须给。friend class DisplayLockGuard让DisplayLockGuard能调 private Lock/Unlock。
8.2.2 DisplayLockGuard —— RAII 锁
class DisplayLockGuard {
public:
DisplayLockGuard(Display *display) : display_(display) {
if (!display_->Lock(30000)) {
ESP_LOGE("Display", "Failed to lock display");
}
}
~DisplayLockGuard() {
display_->Unlock();
}
private:
Display *display_;
};
LVGL 不是线程安全的——任何非 LVGL 任务调 lv_xx API 前必须先 lock。RAII 模式让锁在离开作用域时自动释放,避免漏 unlock 死锁。
典型用法(在 OledDisplay::SetChatMessage 里):
void OledDisplay::SetChatMessage(const char* role, const char* content) {
DisplayLockGuard lock(this); // ← 此处加锁
if (chat_message_label_ == nullptr) return;
// ... 调 lv_label_set_text 等
// 函数返回时锁自动释放
}
8.2.3 NoDisplay —— 哑实现
class NoDisplay : public Display {
private:
virtual bool Lock(int timeout_ms = 0) override { return true; }
virtual void Unlock() override {}
};
板子没屏时用这个,所有接口都是基类的空日志实现。这是Null Object 模式——避免在调用方到处写 if (display) 判空。
8.2.4 SetTheme 持久化
void Display::SetTheme(Theme* theme) {
current_theme_ = theme;
Settings settings("display", true);
settings.SetString("theme", theme->name());
}
主题切换写到 NVS display namespace,下次启动自动恢复。
8.3 OledDisplay —— 单色 OLED 屏
396 行。最常见的低成本 0.96" 128×64 OLED 屏走这条路。
8.3.1 构造 —— LVGL Port 初始化
OledDisplay::OledDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel,
int width, int height, bool mirror_x, bool mirror_y)
: panel_io_(panel_io), panel_(panel) {
width_ = width;
height_ = height;
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_1);
auto dark_theme = new LvglTheme("dark");
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("dark", dark_theme);
current_theme_ = dark_theme;
要点:
- 三种字体:text(正文小字)/ icon(状态栏小图标)/ large_icon(中间大表情);
font_awesome_30_1是 Font Awesome 字体的 30px 子集(项目自带源码font_awesome.h);- OLED 只支持 dark 主题(单色屏没法做 light);
- 注册到全局
LvglThemeManager(让 assets / mcp 的self.screen.set_theme能查到)。
lvgl_port_cfg_t port_cfg = ESP_LVGL_PORT_INIT_CONFIG();
port_cfg.task_priority = 1;
port_cfg.task_stack = 6144;
#if CONFIG_SOC_CPU_CORES_NUM > 1
port_cfg.task_affinity = 1; // 双核时绑核 1
#endif
lvgl_port_init(&port_cfg);
lvgl_port 是 espressif/esp_lvgl_port 组件——把 LVGL 跑在一个独立 FreeRTOS 任务里:
task_priority = 1低优先级,让网络/音频先跑;task_stack = 61446KB 栈,LVGL 调用比较深;- 绑核 1:双核芯片时把 UI 推到 core 1 跟 audio_input(也在 core 1)共享,避免和网络任务(core 0)竞争。注意 audio_input 优先级远高于 UI 所以 UI 不阻塞 audio。
const lvgl_port_display_cfg_t display_cfg = {
.io_handle = panel_io_,
.panel_handle = panel_,
.buffer_size = static_cast<uint32_t>(width_ * height_),
.double_buffer = false,
.hres = static_cast<uint32_t>(width_),
.vres = static_cast<uint32_t>(height_),
.monochrome = true,
.rotation = { .mirror_x = mirror_x, .mirror_y = mirror_y },
.flags = { .buff_dma = 1, .buff_spiram = 0 }
};
display_ = lvgl_port_add_disp(&display_cfg);
buffer_size = width × height:单色屏每像素 1 bit,但 LVGL buffer 还是 8bpp(128×64=8192 = 8KB)—— OLED 这点小不心疼;double_buffer = false:单色屏刷新慢但全屏小,不开双 buffer;monochrome = true:告诉 LVGL 不要走 RGB565 路径;buff_dma = 1:DMA 传输(CPU 不阻塞);buff_spiram = 0:buffer 放 SRAM(PSRAM 对 DMA 不友好)。
if (height_ == 64) {
SetupUI_128x64();
} else {
SetupUI_128x32();
}
64 行高 vs 32 行高两套布局,差别在于 status_bar 位置。
8.3.2 SetupUI_128x64() 布局解构
container_ (128×64, 纵向 flex)
├── top_bar_ (128×16, 横向 flex, space_between)
│ ├── network_label_ ← Wi-Fi/4G 信号图标
│ └── right_icons (横向 flex)
│ ├── mute_label_ ← 静音图标
│ └── battery_label_ ← 电池图标
│
├── status_bar_ (覆盖在 top_bar 上方, 显示状态文字)
│ ├── notification_label_ (默认 hidden)
│ └── status_label_ (LV_LABEL_LONG_SCROLL_CIRCULAR)
│
└── content_ (128×48, 横向 flex, center)
├── content_left_ (32×content)
│ └── emotion_label_ (font_awesome_30_1 字体, 显示大图标)
│
└── content_right_ (content×content, 默认 hidden)
└── chat_message_label_ (滚动字幕)
每个 LVGL 对象都要逐一设置 padding/border/flex/scrollbar:
lv_obj_set_style_pad_all(container_, 0, 0);
lv_obj_set_style_border_width(container_, 0, 0);
lv_obj_set_style_pad_row(container_, 0, 0);
LVGL 风格 API 第二参数是 selector(LV_PART_MAIN | LV_STATE_DEFAULT 等组合,0 = main+default)。
滚动字幕的字体动画:
static lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_delay(&a, 1000);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lv_obj_set_style_anim(chat_message_label_, &a, LV_PART_MAIN);
lv_obj_set_style_anim_duration(chat_message_label_,
lv_anim_speed_clamped(60, 300, 60000), LV_PART_MAIN);
lv_anim_speed_clamped(60, 300, 60000) = "每秒滚 60 像素,最少 300ms 一轮,最多 60 秒一轮"。短文字停顿明显,长文字滚得过去。
8.3.3 SetEmotion() —— 字符转 emoji 字体
void OledDisplay::SetEmotion(const char* emotion) {
const char* utf8 = font_awesome_get_utf8(emotion);
DisplayLockGuard lock(this);
if (emotion_label_ == nullptr) return;
if (utf8 != nullptr) {
lv_label_set_text(emotion_label_, utf8);
} else {
lv_label_set_text(emotion_label_, FONT_AWESOME_NEUTRAL);
}
}
font_awesome_get_utf8 把 "happy"/"sad"/"thinking" 这类英文 emotion key 翻译为 Font Awesome 字体里对应的 UTF-8 字符串(如 \xf118 微笑脸)。LVGL label 把这个字符当成普通字符渲染——只是字体里这个码位实际画的是表情图案。
未知 emotion 时 fallback 到 NEUTRAL(中性脸),不会黑屏。
8.3.4 SetChatMessage() —— 滚动字幕
void OledDisplay::SetChatMessage(const char* role, const char* content) {
DisplayLockGuard lock(this);
if (chat_message_label_ == nullptr) return;
std::string content_str = content;
std::replace(content_str.begin(), content_str.end(), '\n', ' ');
if (content_right_ == nullptr) {
lv_label_set_text(chat_message_label_, content_str.c_str());
} else {
if (content == nullptr || content[0] == '\0') {
lv_obj_add_flag(content_right_, LV_OBJ_FLAG_HIDDEN);
} else {
lv_label_set_text(chat_message_label_, content_str.c_str());
lv_obj_remove_flag(content_right_, LV_OBJ_FLAG_HIDDEN);
}
}
}
- 把所有
\n替换为空格——LVGL 滚动 label 在单行模式下不处理换行; - 空字符串 → hide content_right_(emoji 区域占满屏幕);
- 有内容 → show content_right_(emoji 缩到 32px 左侧,字幕显示右侧)。
8.4 LcdDisplay —— 彩色 LCD 屏
1196 行(实现),最复杂。
8.4.1 三种子类
class LcdDisplay : public LvglDisplay { ... };
class SpiLcdDisplay : public LcdDisplay {
SpiLcdDisplay(io, panel, w, h, ox, oy, mx, my, sx); // SPI 总线
};
class RgbLcdDisplay : public LcdDisplay {
RgbLcdDisplay(io, panel, w, h, ...); // RGB 并行总线
};
class MipiLcdDisplay : public LcdDisplay {
MipiLcdDisplay(io, panel, w, h, ...); // MIPI-DSI(P4)
};
接口完全一致,子类只在构造函数里做不同的 LVGL flush/draw_buf 配置——SPI 屏小,PSRAM 双缓冲;RGB 屏大(800×480),需要部分刷新;MIPI 屏走 DSI 总线。
8.4.2 关键成员
class LcdDisplay : public LvglDisplay {
protected:
esp_lcd_panel_io_handle_t panel_io_;
esp_lcd_panel_handle_t panel_;
lv_draw_buf_t draw_buf_;
lv_obj_t* top_bar_; // 顶部状态栏
lv_obj_t* status_bar_;
lv_obj_t* content_;
lv_obj_t* container_;
lv_obj_t* side_bar_;
lv_obj_t* bottom_bar_;
lv_obj_t* preview_image_; // self.screen.preview_image MCP 工具的预览
lv_obj_t* emoji_label_; // 当用字体表情时
lv_obj_t* emoji_image_; // 当用 GIF/位图表情时
std::unique_ptr<LvglGif> gif_controller_; // GIF 解码器
lv_obj_t* emoji_box_; // emoji 容器
lv_obj_t* chat_message_label_;
esp_timer_handle_t preview_timer_;
std::unique_ptr<LvglImage> preview_image_cached_;
bool hide_subtitle_ = false;
};
比 OLED 多了:
emoji_image_+gif_controller_:彩屏可以播放动画 GIF 表情;preview_image_*:MCP 工具self.screen.preview_image用,显示远程图 5 秒后自动隐藏;hide_subtitle_:从 assets index.json 读取,某些场景关掉字幕只看表情。
8.4.3 SetEmotion 双路径
伪代码(实际 lcd_display.cc 第 600 行附近):
void LcdDisplay::SetEmotion(const char* emotion) {
DisplayLockGuard lock(this);
auto* lvgl_theme = static_cast<LvglTheme*>(current_theme_);
auto emoji_collection = lvgl_theme->emoji_collection();
// 路径 1: 主题里注册了自定义表情图(assets/emoji_collection)→ 显示位图
if (emoji_collection != nullptr) {
auto image = emoji_collection->GetEmoji(emotion);
if (image != nullptr) {
lv_obj_remove_flag(emoji_image_, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(emoji_label_, LV_OBJ_FLAG_HIDDEN);
if (image->is_gif()) {
gif_controller_->SetSource(image); // 播放 GIF
} else {
lv_image_set_src(emoji_image_, image->src());
}
return;
}
}
// 路径 2: 回退到 Font Awesome 字符
lv_obj_add_flag(emoji_image_, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(emoji_label_, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(emoji_label_, font_awesome_get_utf8(emotion));
}
设计思路:
- 优先用资源包里的高分辨率位图(彩色 / 动画);
- 没有则降级到字体(一种字符 = 一种表情,字体单色但通用);
- 路径切换通过 hidden flag 控制 emoji_image_ 和 emoji_label_ 互斥显示。
8.4.4 GIF 表情控制 —— LvglGif
std::unique_ptr<LvglGif> gif_controller_;
LcdDisplay 持有 GIF 解码器。GIF 是分帧的,需要按 fps 定期切下一帧——LvglGif::SetSource(image) 内部启 esp_timer 周期刷帧。
注意是 unique_ptr——切换到字体表情时直接 gif_controller_.reset() 释放解码器和内存。
8.4.5 SetPreviewImage() —— 临时图像预览
void LcdDisplay::SetPreviewImage(std::unique_ptr<LvglImage> image) {
DisplayLockGuard lock(this);
if (preview_timer_) esp_timer_stop(preview_timer_);
preview_image_cached_ = std::move(image);
lv_image_set_src(preview_image_, preview_image_cached_->src());
lv_obj_remove_flag(preview_image_, LV_OBJ_FLAG_HIDDEN);
esp_timer_start_once(preview_timer_, PREVIEW_IMAGE_DURATION_MS * 1000);
}
5 秒后 timer 触发隐藏 preview_image_ 并 reset 缓存——避免内存常驻。这是 self.screen.preview_image MCP 工具的实现核心(第 6 章 6.7.3)。
8.4.6 三种总线的特殊化
SPI(最常见,小屏 ≤320×240):
SpiLcdDisplay::SpiLcdDisplay(io, panel, w, h, ox, oy, mx, my, sx) {
// 用 PSRAM 分配 draw_buf (单 buffer,全屏字节 = w*h*2 字节 RGB565)
// 全屏 buffer:刷新慢但帧间无撕裂
// partial mode: 只重画 dirty 区域
}
RGB(大屏 ≥480×320,并行总线带宽够):
RgbLcdDisplay::RgbLcdDisplay(...) {
// 用专用 RGB framebuffer
// direct_mode:LVGL 直接写 framebuffer,无需 flush
// 双 buffer:撕裂稍可见但帧率高
}
MIPI(仅 ESP32-P4,HD 屏 800×1280):
MipiLcdDisplay::MipiLcdDisplay(...) {
// 类似 RGB 但走 MIPI-DSI 总线(差分串行,速度更快)
// 通常配 PPA 硬件加速
}
具体的总线差异在每种屏的 init 函数里——读者用到时再深入。
8.5 EmoteDisplay —— 表情动画专用屏
657 行。这是个绕开 LVGL 的特殊路径,用于追求"主屏只显示一个大表情 + 极少字幕"场景,对刷新率和动画流畅度极致优化。
8.5.1 为什么不用 LVGL
- LVGL 渲染管线在 ESP32-C3 / S3 这种 SRAM 紧张的芯片上跑 480×480 全屏动画很吃力(每帧要做 alpha 合成 / 抗锯齿);
- 表情动画需求简单:固定 fps + 全屏直贴;
- 走自定义
EmoteEngine(项目里以 .a 静态库形式提供)能用硬件 PPA 直接 DMA 推到屏。
8.5.2 AssetData —— 位字段压缩
struct AssetData {
const void* data;
size_t size;
union {
uint8_t flags;
struct {
uint8_t fps : 6; // 0-63
uint8_t loop : 1;
uint8_t lack : 1;
};
};
};
union + 位字段:
- 一个字节 = 8 位,分成 6 + 1 + 1 三个字段;
- fps 6 位最大 63,对 60fps 也够;
loop:是否循环播放;lack:是否有"缺帧补帧"逻辑;- 通过 union 既能整字节读
flags(拷贝/重置),也能位字段语义访问。
构造时主动 clamp:
AssetData(const void* d, size_t s, uint8_t f, bool l, bool k) : data(d), size(s) {
fps = f > 63 ? 63 : f; // 防溢出
loop = l;
lack = k;
}
8.5.3 LayoutData —— 元素定位
struct LayoutData {
char align; // 单字符 = 一种对齐方式
int x, y, width, height;
bool has_size;
};
char StringToGfxAlign(const std::string &align_str);
align_str 是 assets 里写的 "top_left" / "center" / "bottom_right" 等字符串,运行时转成 EmoteEngine 内部用的单字符代码(节省 50% 内存)。
8.5.4 接口同 Display 但内部走 EmoteEngine
class EmoteDisplay : public Display {
public:
virtual void SetEmotion(const char* emotion) override;
virtual void SetStatus(const char* status) override;
virtual void SetChatMessage(const char* role, const char* content) override;
virtual void SetTheme(Theme* theme) override;
virtual void ShowNotification(const char* notification, int duration_ms = 3000) override;
virtual void UpdateStatusBar(bool update_all = false) override;
virtual void SetPowerSaveMode(bool on) override;
virtual void SetPreviewImage(const void* image);
void AddEmojiData(const std::string &name, const void* data, size_t size, uint8_t fps = 0, bool loop = false, bool lack = false);
void AddIconData(const std::string &name, const void* data, size_t size);
void AddLayoutData(...);
void AddTextFont(std::shared_ptr<LvglFont> text_font);
private:
std::unique_ptr<EmoteEngine> engine_;
std::shared_ptr<LvglFont> text_font_;
std::map<std::string, AssetData> emoji_data_map_;
std::map<std::string, AssetData> icon_data_map_;
};
assets.cc 在 emote 路径下调用 AddEmojiData / AddIconData / AddLayoutData 把资源送进 EmoteDisplay 的 map。SetEmotion 时根据 emotion key 从 map 查表,再交给 engine_ 推到屏。
EmoteEngine 本身是闭源的(libs/ 下的 .a),内部包含:
- EAF 格式(Emote Animation Frame)解码器;
- 软硬件混合的帧合成器;
- DMA 推帧到屏。
虽然源码不可见,但接口在 emote_display.h 里完整,从外部就能正确驱动。
8.6 LED 抽象与三种实现
8.6.1 Led 接口
class Led {
public:
virtual ~Led() = default;
virtual void OnStateChanged() = 0;
};
class NoLed : public Led {
public:
virtual void OnStateChanged() override {}
};
接口极简——只有一个 OnStateChanged() 纯虚函数。
由 Application::HandleStateChangedEvent 在所有设备状态变化时调用:
void Application::HandleStateChangedEvent() {
auto led = board_->GetLed();
led->OnStateChanged();
// ...
}
LED 子类自己内部查 Application 的当前状态做对应的动作。
8.6.2 SingleLed —— 单粒 WS2812B 全彩
163 行。最常见的板子上唯一一颗状态灯(WS2812B 是带控制器的智能 LED,能输出 24bit 任意颜色)。
初始化:RMT 外设驱动 WS2812
SingleLed::SingleLed(gpio_num_t gpio) {
led_strip_config_t strip_config = {};
strip_config.strip_gpio_num = gpio;
strip_config.max_leds = 1;
strip_config.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB;
strip_config.led_model = LED_MODEL_WS2812;
led_strip_rmt_config_t rmt_config = {};
rmt_config.resolution_hz = 10 * 1000 * 1000; // 10MHz
ESP_ERROR_CHECK(led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip_));
led_strip_clear(led_strip_);
RMT 是 ESP32 的红外遥控外设,但常被借用来生成 WS2812B 需要的特定时序(高电平 0.4μs/0.8μs 等编码 0/1)。led_strip 组件封装了这套逻辑。
esp_timer_create_args_t blink_timer_args = {
.callback = [](void *arg) {
auto led = static_cast<SingleLed*>(arg);
led->OnBlinkTimer();
},
.arg = this,
.dispatch_method = ESP_TIMER_TASK,
.name = "blink_timer",
};
esp_timer_create(&blink_timer_args, &blink_timer_);
}
闪烁靠 esp_timer 周期触发 OnBlinkTimer,ESP_TIMER_TASK 在专用 timer 任务里调度(不在 ISR 里跑),可以调任意 API。
OnBlinkTimer() 实现闪烁
void SingleLed::OnBlinkTimer() {
std::lock_guard<std::mutex> lock(mutex_);
blink_counter_--;
if (blink_counter_ & 1) { // 奇数 → 亮
led_strip_set_pixel(led_strip_, 0, r_, g_, b_);
led_strip_refresh(led_strip_);
} else { // 偶数 → 灭
led_strip_clear(led_strip_);
if (blink_counter_ == 0) {
esp_timer_stop(blink_timer_);
}
}
}
巧用计数器奇偶:
- 初始
blink_counter_ = times * 2(每次闪烁 = 亮一次 + 灭一次); - 每次 timer tick 减 1;
- 奇数亮、偶数灭;
- 减到 0 自动停。
无限闪烁时 times = BLINK_INFINITE = -1,乘 2 = 0xFFFE,几乎用不完。
OnStateChanged() —— 状态到颜色的映射表
void SingleLed::OnStateChanged() {
auto& app = Application::GetInstance();
auto device_state = app.GetDeviceState();
switch (device_state) {
case kDeviceStateStarting:
SetColor(0, 0, DEFAULT_BRIGHTNESS); // 蓝
StartContinuousBlink(100); // 快闪
break;
case kDeviceStateWifiConfiguring:
SetColor(0, 0, DEFAULT_BRIGHTNESS); // 蓝
StartContinuousBlink(500); // 慢闪
break;
case kDeviceStateIdle:
TurnOff(); // 关
break;
case kDeviceStateConnecting:
SetColor(0, 0, DEFAULT_BRIGHTNESS); // 蓝
TurnOn(); // 常亮
break;
case kDeviceStateListening:
case kDeviceStateAudioTesting:
if (app.IsVoiceDetected()) {
SetColor(HIGH_BRIGHTNESS, 0, 0); // 强红(说话中)
} else {
SetColor(LOW_BRIGHTNESS, 0, 0); // 弱红(静音中)
}
TurnOn();
break;
case kDeviceStateSpeaking:
SetColor(0, DEFAULT_BRIGHTNESS, 0); // 绿
TurnOn();
break;
case kDeviceStateUpgrading:
SetColor(0, DEFAULT_BRIGHTNESS, 0); // 绿
StartContinuousBlink(100); // 快闪
break;
case kDeviceStateActivating:
SetColor(0, DEFAULT_BRIGHTNESS, 0); // 绿
StartContinuousBlink(500); // 慢闪
break;
}
}
状态→颜色映射的设计准则:
- 冷色(蓝)= 系统/网络忙(启动/配网/连接);
- 暖色(红)= 在听用户(高亮度=正在说话,低亮度=静音);
- 绿 = 在说话给用户 / 系统就绪状态(speaking/upgrade/activate);
- 快闪 (100ms) = 关键事件(启动、升级);
- 慢闪 (500ms) = 等待状态(配网、激活);
- 常亮 = 进行中(已连接、说话中);
- 熄灭 = 待机(idle)。
注意 Listening 状态里还会根据 VAD(语音检测)实时切换亮度——能直观看到设备"听到我说话了"。
8.6.3 CircularStrip —— 环形灯带(多粒 LED)
233 行。某些表盘式板子(如 box-3)周边一圈 12 颗 WS2812B 做装饰。
数据结构
struct StripColor { uint8_t red, green, blue; };
class CircularStrip {
std::vector<StripColor> colors_; // 每颗 LED 的当前颜色
uint8_t max_leds_;
uint8_t default_brightness_ = ...;
uint8_t low_brightness_ = ...;
std::function<void()> strip_callback_; // 每帧执行的闭包
esp_timer_handle_t strip_timer_;
};
strip_callback_ 是 std::function——可以装任意 lambda,每个动画效果就是一个不同的 lambda。
4 种动画效果
1. Blink —— 整体闪烁
void CircularStrip::Blink(StripColor color, int interval_ms) {
for (int i = 0; i < max_leds_; i++) colors_[i] = color;
StartStripTask(interval_ms, [this]() {
static bool on = true;
if (on) {
for (int i = 0; i < max_leds_; i++)
led_strip_set_pixel(led_strip_, i, colors_[i].red, ...);
led_strip_refresh(led_strip_);
} else {
led_strip_clear(led_strip_);
}
on = !on;
});
}
注意 static bool on —— 每个 lambda 拷贝各自一份 static,因为是匿名 lambda 类。能跨 timer 调用持续。
2. FadeOut —— 逐渐变暗
void CircularStrip::FadeOut(int interval_ms) {
StartStripTask(interval_ms, [this]() {
bool all_off = true;
for (int i = 0; i < max_leds_; i++) {
colors_[i].red /= 2; // 每次减半
colors_[i].green /= 2;
colors_[i].blue /= 2;
if (colors_[i].red != 0 || colors_[i].green != 0 || colors_[i].blue != 0)
all_off = false;
led_strip_set_pixel(led_strip_, i, colors_[i].red, ...);
}
if (all_off) {
led_strip_clear(led_strip_);
esp_timer_stop(strip_timer_);
} else {
led_strip_refresh(led_strip_);
}
});
}
几何衰减(每次减半)—— 比线性减更有"渐隐"感(人眼对亮度感知接近对数)。
3. Breathe —— 呼吸
void CircularStrip::Breathe(StripColor low, StripColor high, int interval_ms) {
StartStripTask(interval_ms, [this, low, high]() {
static bool increase = true;
static StripColor color = low;
if (increase) {
// 每个分量 +1
color.red < high.red ? color.red++ : 0;
// ...
if (color reaches high) increase = false;
} else {
// 每个分量 -1
if (color reaches low) increase = true;
}
// refresh all pixels
});
}
像人呼吸一样亮度起伏。[this, low, high] 值捕获 low/high——避免 lambda 持有的引用悬空。
4. Scroll —— 旋转
void CircularStrip::Scroll(StripColor low, StripColor high, int length, int interval_ms) {
// 全部底色为 low
for (int i = 0; i < max_leds_; i++) colors_[i] = low;
StartStripTask(interval_ms, [this, low, high, length]() {
static int offset = 0;
// 重置为低色
for (int i = 0; i < max_leds_; i++) colors_[i] = low;
// 在 offset 位置画 length 颗高色
for (int j = 0; j < length; j++) {
int i = (offset + j) % max_leds_;
colors_[i] = high;
}
// refresh
offset = (offset + 1) % max_leds_; // 下一帧偏移 +1
});
}
模拟"一段亮带"沿环形顺时针转。offset % max_leds_ 让它无限循环。
OnStateChanged() 映射到不同动画
case kDeviceStateStarting:
Scroll({0,0,0}, {low_b, low_b, def_b}, 3, 100); // 蓝色 3 颗滚动
break;
case kDeviceStateWifiConfiguring:
Blink({low_b, low_b, def_b}, 500); // 蓝闪
break;
case kDeviceStateIdle:
FadeOut(50); // 渐隐
break;
case kDeviceStateConnecting:
SetAllColor({low_b, low_b, def_b}); // 全蓝
break;
case kDeviceStateListening:
case kDeviceStateAudioTesting:
SetAllColor({def_b, low_b, low_b}); // 全红
break;
case kDeviceStateSpeaking:
SetAllColor({low_b, def_b, low_b}); // 全绿
break;
case kDeviceStateUpgrading:
Blink({low_b, def_b, low_b}, 100); // 绿快闪
break;
case kDeviceStateActivating:
Blink({low_b, def_b, low_b}, 500); // 绿慢闪
break;
比 SingleLed 多了:
- Starting → Scroll(旋转感);
- Idle → FadeOut(不是立即灭,有"睡眠"渐变)。
8.6.4 GpioLed —— 普通 GPIO + PWM 单色
262 行。最朴素的"一颗带电阻的 LED 接 GPIO",无颜色控制,但能调亮度。
LEDC 外设原理
ESP32 的 LEDC(LED Controller)是专门的 PWM 外设:
- 硬件定时器周期翻转 GPIO;
- duty cycle = on-time / period 决定亮度(0-100%);
- 硬件自带 fade 功能(从 duty A 平滑变到 duty B),CPU 不用参与。
GpioLed::GpioLed(gpio_num_t gpio, int output_invert, ledc_timer_t timer, ledc_channel_t channel) {
ledc_timer_config_t ledc_timer = {};
ledc_timer.duty_resolution = LEDC_TIMER_13_BIT; // 8192 档亮度
ledc_timer.freq_hz = 4000; // 4 kHz PWM
ledc_timer.speed_mode = LEDC_LS_MODE;
ledc_timer.timer_num = timer_num;
ledc_timer_config(&ledc_timer);
ledc_channel_.channel = channel;
ledc_channel_.gpio_num = gpio;
ledc_channel_.timer_sel = timer_num;
ledc_channel_.flags.output_invert = output_invert & 0x01;
ledc_channel_config(&ledc_channel_);
ledc_fade_func_install(0);
ledc_cbs_t ledc_callbacks = { .fade_cb = FadeCallback };
ledc_cb_register(speed_mode, channel, &ledc_callbacks, this);
xTaskCreate(EventTask, "LedEvent", 2048, this, tskIDLE_PRIORITY + 2, &event_task_handle_);
}
- 13 位 duty 分辨率 = 8192 档亮度(眼睛根本分不清这么多档,做 fade 时极平滑);
- 4 kHz PWM 频率,远高于人眼闪烁感知阈值;
output_invert:有些板子 LED 接 VCC + GPIO(低电平亮),加这个翻转;- 注册
FadeCallback让 fade 完成后通知; - 创建专用 event_task 处理 fade end 事件。
StartFadeTask() —— 硬件呼吸
void GpioLed::StartFadeTask() {
std::lock_guard<std::mutex> lock(mutex_);
fade_up_ = true;
ledc_set_fade_with_time(speed_mode, channel, LEDC_DUTY, LEDC_FADE_TIME);
ledc_fade_start(speed_mode, channel, LEDC_FADE_NO_WAIT);
}
void GpioLed::OnFadeEnd() {
std::lock_guard<std::mutex> lock(mutex_);
fade_up_ = !fade_up_;
ledc_set_fade_with_time(speed_mode, channel, fade_up_ ? LEDC_DUTY : 0, LEDC_FADE_TIME);
ledc_fade_start(speed_mode, channel, LEDC_FADE_NO_WAIT);
}
bool IRAM_ATTR GpioLed::FadeCallback(const ledc_cb_param_t *param, void *user_arg) {
if (param->event == LEDC_FADE_END_EVT) {
auto led = static_cast<GpioLed*>(user_arg);
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xTaskNotifyFromISR(led->event_task_handle_, 0x01, eSetValueWithOverwrite, &xHigherPriorityTaskWoken);
}
return true;
}
void GpioLed::EventTask(void* arg) {
GpioLed* led = static_cast<GpioLed*>(arg);
while (1) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
led->OnFadeEnd();
}
}
完整的硬件呼吸流程:
- CPU 调
ledc_set_fade_with_time(8191, 1000ms)+ledc_fade_start→ CPU 撒手,硬件接管; - LEDC 硬件 1 秒内逐级把 duty 从 0 提到 8191;
- fade 结束,硬件触发 ISR
FadeCallback; FadeCallback在 ISR 里只做一件事:xTaskNotifyFromISR通知 event_task;- event_task 醒来调
OnFadeEnd; OnFadeEnd翻转fade_up_设新目标值并fade_start;- 循环往复 = 呼吸效果。
为什么不能在 ISR 里直接调 ledc_set_fade_with_time?因为这个函数内部用了互斥锁,ISR 不能 take 锁。用 TaskNotify 把 ISR 工作搬到 task 是 ESP-IDF 标准模式。
IRAM_ATTR 属性:把 FadeCallback 放到内置 SRAM 而非 Flash,避免 ISR 访问 flash 阻塞。
OnStateChanged 状态映射
跟 SingleLed 结构一样,但用 SetBrightness 代替 SetColor,listening 时调 StartFadeTask 而不是简单 TurnOn——单色 LED 没法用颜色区分状态,只能靠"明暗节奏"。
8.7 Display + LED + State 状态联动总图
Application::HandleStateChangedEvent (主循环上)
├─ board.GetLed()->OnStateChanged() [LED 类按自己逻辑变色/闪烁]
├─ board.GetDisplay()->SetStatus(...) [屏顶状态栏更新文字]
├─ board.GetDisplay()->SetChatMessage(...) [字幕滚动]
├─ audio_service.EnableVoiceProcessing(true) [开/关 AFE]
└─ ...
Listening 状态特殊处理(VAD 中变化):
audio_processor 检测到 VadStateChange
→ on_vad_state_change 回调
→ MAIN_EVENT_VAD_STATE_CHANGED
→ Application 重新调 led->OnStateChanged() ← LED 实时反映"有声音 / 静音"
8.8 本章用到的关键技术
| 技术 | 应用 |
|---|---|
| 多态 + Null Object 模式 | NoDisplay / NoLed |
| RAII 锁守卫 | DisplayLockGuard |
| LVGL flex 布局 | OLED/LCD 容器嵌套 |
| LVGL label long mode + anim | 滚动字幕 |
font_awesome_get_utf8 | 字符串 → 字体码位 |
| 位图 + GIF + 字体三层 emoji fallback | LcdDisplay::SetEmotion |
| 位字段 union | EmoteDisplay::AssetData |
| lvgl_port + task affinity | UI 任务绑核 1 |
| RMT 驱动 WS2812B | SingleLed/CircularStrip |
| 奇偶计数器 blink | 简洁的闪烁实现 |
| std::function + 闭包 | CircularStrip 4 种动画 |
| lambda 值捕获 vs 引用捕获 | 延迟执行的安全 |
| lambda 内 static 变量 | 跨 timer 调用持久状态 |
| LEDC 外设 + 13bit duty + 4kHz | GpioLed 平滑亮度 |
| 硬件 fade + ISR + TaskNotify | 不占 CPU 的呼吸效果 |
| IRAM_ATTR | ISR 放内置 SRAM |
| 几何衰减(除 2 减半) | FadeOut 渐隐符合人眼感知 |
| 状态 → 颜色/节奏 映射表 | 用户体验一致性 |
8.9 看完本章你应该掌握的
- Display / Led 两个抽象基类的设计
- DisplayLockGuard RAII 守卫
- OledDisplay 128×64 / 128×32 两套 LVGL 布局
- Font Awesome 字符级 emoji
- LcdDisplay 三种总线(SPI/RGB/MIPI)的差别
- LcdDisplay 位图+GIF+字体三层 fallback
- EmoteDisplay 走自家 EmoteEngine 的原因(流畅度优化)
- AssetData 用位字段压缩存储
- SingleLed 状态→颜色映射的设计哲学
- CircularStrip 4 种动画(Blink/FadeOut/Breathe/Scroll)实现
- GpioLed 用 LEDC 硬件 fade 实现呼吸(CPU 零占用)
- ISR → TaskNotify → Task 的标准 ESP-IDF 模式
下一章覆盖 boards/ 板级抽象 + 几个代表性板子细节。