跳到主要内容

第 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 &notification, 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 DisplayLockGuardDisplayLockGuard 能调 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 = 6144 6KB 栈,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 周期触发 OnBlinkTimerESP_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();
}
}

完整的硬件呼吸流程

  1. CPU 调 ledc_set_fade_with_time(8191, 1000ms) + ledc_fade_start → CPU 撒手,硬件接管;
  2. LEDC 硬件 1 秒内逐级把 duty 从 0 提到 8191;
  3. fade 结束,硬件触发 ISR FadeCallback
  4. FadeCallback 在 ISR 里只做一件事:xTaskNotifyFromISR 通知 event_task;
  5. event_task 醒来调 OnFadeEnd
  6. OnFadeEnd 翻转 fade_up_ 设新目标值并 fade_start
  7. 循环往复 = 呼吸效果。

为什么不能在 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 fallbackLcdDisplay::SetEmotion
位字段 unionEmoteDisplay::AssetData
lvgl_port + task affinityUI 任务绑核 1
RMT 驱动 WS2812BSingleLed/CircularStrip
奇偶计数器 blink简洁的闪烁实现
std::function + 闭包CircularStrip 4 种动画
lambda 值捕获 vs 引用捕获延迟执行的安全
lambda 内 static 变量跨 timer 调用持久状态
LEDC 外设 + 13bit duty + 4kHzGpioLed 平滑亮度
硬件 fade + ISR + TaskNotify不占 CPU 的呼吸效果
IRAM_ATTRISR 放内置 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/ 板级抽象 + 几个代表性板子细节。