ESP-IDF 刷新与低功耗
前两篇已经完成了页面开发、外设接入和交互事件处理。本篇继续使用同一个综合开发 Demo(Sticky_dashboard_demo),介绍电子纸应用中的两项关键能力:根据画面内容选择刷新方式,以及在保留屏幕内容的同时进入低功耗状态。
本篇主要介绍:
- 区分四阶灰度全刷、黑白全刷和黑白局刷
- 使用 RTC 分钟事件更新黑白页面的状态栏时间
- 根据内容变化设计按需刷新策略
- 通过 AI 键进入深度睡眠并再次唤醒设备
- 在唤醒后恢复睡眠前的页面状态
开始前,请先完成 ESP-IDF 开发基础 和 ESP-IDF 页面与外设,并确认该 Demo 能够正常切换页面、读取 RTC 时间和响应 AI 键。
刷新方式
电子纸可在断电后持续保留画面,适合低频更新、长期可见的信息展示。为了发挥这一特性,应用通常在内容发生变化时触发刷新,并根据画面内容选择合适的刷新方式。
Demo 提供了三种刷新接口:
| 刷新方式 | 接口 | Demo 中的场景 |
|---|---|---|
| 四阶灰度全刷 | sticky_display_refresh() | 显示 Home、切回 Home 或在 Home 短按 AI |
| 黑白全刷 | sticky_display_refresh_monochrome() | 显示或刷新 Sensor、Battery、Note、IMU、Microphone 和 Deep Sleep 页面 |
| 黑白局刷 | sticky_display_refresh_partial() | 每分钟更新非 Home 页面的状态栏时间 |
四阶灰度全刷
Home 是 Demo 中唯一使用四阶灰度的页面,用来展示黑色、深灰、浅灰和白色。App 层通过统一接口为当前页面选择刷新方式:
bool page_uses_gray4(PageId page)
{
return page == PageId::Home;
}
esp_err_t refresh_current_page(const AppState &state)
{
return page_uses_gray4(state.current_page)
? sticky_display_refresh()
: sticky_display_refresh_monochrome();
}
页面绘制完成后,页面切换、AI 短按刷新和 IMU 页面更新都会调用 refresh_current_page()。当当前页面是 Home 时,sticky_display_refresh() 会读取完整 Canvas,处理设备所需的 180° 旋转,并使用四阶灰度模式刷新整个屏幕。
黑白全刷
Sensor、Battery、Note、IMU 和 Microphone 页面都只使用黑色和白色,因此 refresh_current_page() 会为它们选择黑白全刷。Deep Sleep 页面同样是纯黑白画面,进入睡眠前直接调用:
sleep_page_render(canvas);
ESP_RETURN_ON_ERROR(
sticky_display_refresh_monochrome(),
"app", "refresh deep sleep page");
这个接口会把 Canvas 转换为单色画面,并用黑白全刷模式更新整个屏幕。它适合只包含 GrayLevel::Black 和 GrayLevel::White 的页面;需要保留四阶灰度效果的 Home 则使用四阶灰度全刷。
黑白局刷
RTC 时间每分钟只变化一次,而且只占状态栏中的一小块区域。Sensor、Battery、Note、IMU 和 Microphone 页面会显示状态栏时间;分钟变化时,Demo 只修改时间区域,再调用:
sticky_display_refresh_partial();
Home 不显示状态栏时间,也不会执行 RTC 局刷。这样可以让 Home 始终保留完整的四阶灰度效果,同时让其他页面继续使用高效的分钟局刷。
局刷适合小范围的黑白内容更新;切换完整页面、更新大面积内容或显示四阶灰度时,使用对应的全刷方式可以获得更加一致的显示效果。
选择刷新方式时,可以先判断画面是否需要灰阶、变化范围是否足够小,以及变化内容是否为纯黑白。Demo 只在黑白页面上局刷状态栏时间,其他页面更新仍使用与当前页面匹配的全刷方式。
RTC 时间局刷
非 Home 页面的 RTC 时间更新把前两篇介绍的 RTC、AppState、AppEvent、Canvas 和 Display 串联在一起:
RTC 分钟变化
-> app_clock 发送 ClockMinuteTick
-> App 层重新读取 RTC
-> 更新 AppState.date_time
-> 重绘状态栏时间区域
-> 执行黑白局刷
相关代码位于:
main/app/app_clock.cpp
main/app/app.cpp
main/app/app_data.cpp
main/ui/status_bar.cpp
main/devices/sticky_display.cpp
RTC 分钟定时
首次显示页面后,app_main() 使用刚读取到的 RTC 秒数启动分钟定时器:
ESP_ERROR_CHECK(
app_clock_start(state.date_time.valid ? state.date_time.second : -1));
app_clock_start() 根据当前秒数计算距离下一分钟的时间。如果 RTC 数据有效,还会在分钟边界后留出 250 ms 的余量,再发送第一次事件。后续每隔 60 秒继续触发一次。
定时器回调不直接访问 RTC 或屏幕,只把 ClockMinuteTick 放入应用事件队列:
void minute_timer_callback(void *)
{
if (!app_event_post(AppEvent::ClockMinuteTick)) {
ESP_LOGW(kTag, "Clock minute event queue is full");
}
const esp_err_t result =
esp_timer_start_once(s_minute_timer, kMinutePeriodUs);
if (result != ESP_OK) {
ESP_LOGE(kTag, "Restart minute timer failed: %s",
esp_err_to_name(result));
}
}
这样,RTC 读取、Canvas 绘制和电子纸刷新仍由主事件循环依次完成,不会与按键、触摸等事件同时修改应用状态。
时间变化检测
收到 ClockMinuteTick 后,App 层先保存原来的时间,再读取一次实体 RTC。只有有效状态、小时或分钟发生变化时,才继续重绘:
if (event == AppEvent::ClockMinuteTick) {
const bool previous_valid = state.date_time.valid;
const int previous_hour = state.date_time.hour;
const int previous_minute = state.date_time.minute;
update_date_time(state);
const bool time_changed =
previous_valid != state.date_time.valid ||
(state.date_time.valid &&
(previous_hour != state.date_time.hour ||
previous_minute != state.date_time.minute));
if (!time_changed) {
return ESP_OK;
}
if (page_uses_gray4(state.current_page)) {
ESP_LOGI(kTag, "Skipping RTC partial refresh on gray4 Home Page");
return ESP_OK;
}
status_bar_render_time(canvas, state);
ESP_LOGI(kTag, "Partially refreshing RTC time");
return sticky_display_refresh_partial();
}
这一步避免了 RTC 数据没有变化时仍然刷新电子纸。Home 页面仍会更新 AppState.date_time,但因为它不显示状态栏时间,所以不会修改 Canvas 或执行局刷。
状态栏时间更新
在非 Home 页面中,status_bar_render_time() 先把原来的时间区域填充为白色,再绘制新的 HH:MM:
canvas.fill_rect(kStatusBarTimeX,
kStatusBarTimeY,
kStatusBarTimeWidth,
kStatusBarTimeHeight,
GrayLevel::White);
char time_text[6] = "--:--";
if (state.date_time.valid) {
std::snprintf(time_text, sizeof(time_text), "%02d:%02d",
state.date_time.hour, state.date_time.minute);
}
canvas.draw_text(670, 52, time_text, 2, GrayLevel::Black);
先清除旧内容很重要。如果直接在原时间上绘制新字符,旧像素会留在 Canvas 中,画面可能出现数字重叠。
局刷比较帧
从页面绘制的角度看,Demo 只修改了黑白页面的时间区域;从 Display 层看,当前设备使用 SSD1677 电子纸控制器,局刷过程需要一帧完整的黑白画面进行前后比较。
Canvas 只修改时间区域
-> Display 将完整 Canvas 转换为黑白帧
-> 电子纸控制器比较前后画面
-> 相同像素保持不变
-> 视觉上只有时间区域更新
因此,sticky_display_refresh_partial() 会提交完整的黑白比较帧,而不是只向屏幕发送一个小矩形。页面侧只修改需要更新的 Canvas 区域;画面旋转、黑白转换和比较帧传输由 Display 层统一处理。Home 不进入这条局刷链路,从而保持四阶灰度画面不变。
为了让长时间局刷后的画面保持一致,应用可以在合适的时机执行一次全刷。Demo 中,短按 AI 键会发送 RefreshPage 并重新绘制当前页面:Home 使用四阶灰度全刷,其他页面使用黑白全刷。
进入深度睡眠
在第二篇中,AI 键长按约两秒会发送 AppEvent::EnterDeepSleep。App 层收到事件后,不会立即调用 esp_deep_sleep_start(),而是先完成页面和外设的收尾工作:
AI 键长按
-> 蜂鸣提示
-> 保存 AppState
-> 绘制 Deep Sleep 页面
-> 黑白全屏刷新
-> 停止 Touch 轮询
-> 电子纸控制器休眠并关闭显示电源
-> ESP32-S3 进入 Deep Sleep
对应的应用代码如下:
if (event == AppEvent::EnterDeepSleep) {
ESP_LOGI(kTag, "Preparing for deep sleep");
const esp_err_t beep_result = sticky_buzzer_beep();
if (beep_result != ESP_OK) {
ESP_LOGW(kTag, "Deep-sleep feedback beep failed: %s",
esp_err_to_name(beep_result));
}
save_app_state_for_deep_sleep(state);
sleep_page_render(canvas);
ESP_RETURN_ON_ERROR(
sticky_display_refresh_monochrome(),
"app", "refresh deep sleep page");
ESP_RETURN_ON_ERROR(
sticky_touch_stop(), "app", "stop touch polling");
ESP_RETURN_ON_ERROR(
sticky_display_sleep(), "app", "sleep display");
sticky_power_enter_deep_sleep();
}
休眠页面显示
main/pages/sleep_page.cpp 绘制的是一个纯黑白页面:
void sleep_page_render(Canvas &canvas)
{
canvas.clear(GrayLevel::White);
canvas.draw_rect(170, 105, 460, 270, GrayLevel::Black);
canvas.draw_rect(178, 113, 444, 254, GrayLevel::Black);
canvas.draw_text(250, 180, "Deep Sleep", 4, GrayLevel::Black);
canvas.draw_line(230, 245, 570, 245, GrayLevel::Black);
canvas.draw_text(250, 290, "Press AI to wake", 2, GrayLevel::Black);
}
页面完成黑白全刷后,sticky_display_sleep() 会让电子纸控制器进入休眠并关闭显示电源。电子纸可以在没有持续刷新和供电的情况下保持最后画面,因此设备进入深度睡眠后,Deep Sleep 页面仍会稳定显示在屏幕上。
AI 键释放检测
进入 Deep Sleep 前,sticky_power_enter_deep_sleep() 会等待 AI 键松开,并在松开后留出短暂的消抖时间:
while (gpio_get_level(static_cast<gpio_num_t>(PIN_POWER_BTN)) == 0) {
vTaskDelay(kReleasePollInterval);
}
vTaskDelay(kReleaseDebounceTime);
AI 键采用低电平唤醒。如果长按动作尚未结束就直接进入 Deep Sleep,仍然保持低电平的按键可能立即把设备唤醒。等待按键释放,可以避免设备进入深度睡眠后被同一次按压立即唤醒。
随后,电源模块保持必要的系统供电控制,并将电子纸、触摸、麦克风、MicroSD 和蜂鸣器的控制引脚锁定在关闭电平,最后进入 ESP32-S3 Deep Sleep。
睡眠唤醒
Demo 使用 AI 键所在的 GPIO4 作为 Deep Sleep 唤醒源。该按键为低电平有效,因此电源模块使用 EXT1 的低电平唤醒方式:
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(
1ULL << PIN_POWER_BTN, ESP_EXT1_WAKEUP_ANY_LOW));
按下 AI 键后,ESP32-S3 会重新执行 app_main(),并不是从 esp_deep_sleep_start() 后面继续运行。Board、Display、Touch 和其他 Device 模块都会重新初始化。
按下 AI 键
-> GPIO4 触发 EXT1 唤醒
-> 重新进入 app_main()
-> 初始化 Board 和各 Device 模块
-> 恢复睡眠前的 AppState
-> 重新读取 RTC 时间
-> 绘制恢复后的页面
-> 根据恢复页面执行四阶灰度或黑白全刷
睡眠前状态保存
Demo 使用 RTC 保留内存保存 AppState,并通过 Magic 值标记数据是否有效:
constexpr uint32_t kSavedStateMagic = 0x53544943U; // "STIC"
RTC_NOINIT_ATTR uint32_t s_saved_state_magic;
RTC_NOINIT_ATTR uint8_t s_saved_state[sizeof(AppState)];
void save_app_state_for_deep_sleep(const AppState &state)
{
std::memcpy(s_saved_state, &state, sizeof(state));
s_saved_state_magic = kSavedStateMagic;
}
这部分内存在 Deep Sleep 期间继续保留,因此可以记录当前 PageId 和页面已有状态。普通上电或复位不会被误认为是睡眠唤醒,因为恢复函数还会检查唤醒原因。
页面状态恢复
只有唤醒原因是 EXT1 且 Magic 值正确时,Demo 才恢复保存的数据:
bool restore_app_state_after_deep_sleep(AppState &state)
{
if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_EXT1 ||
s_saved_state_magic != kSavedStateMagic) {
return false;
}
std::memcpy(&state, s_saved_state, sizeof(state));
s_saved_state_magic = 0;
return true;
}
app_main() 随后重新读取实体 RTC,再绘制初始页面。show_initial_page() 也调用统一的 refresh_current_page():
void show_initial_page(Canvas &canvas, const AppState &state)
{
render_current_page(canvas, state);
ESP_ERROR_CHECK(refresh_current_page(state));
}
AppState state;
restore_app_state_after_deep_sleep(state);
update_date_time(state);
show_initial_page(*canvas, state);
这样可以恢复睡眠前显示的页面,并使用该页面对应的刷新方式:恢复到 Home 时执行四阶灰度全刷,恢复到其他页面时执行黑白全刷。非 Home 页面的状态栏时间来自唤醒后重新读取的 RTC 数据,而不是睡眠前保存的旧时间。
Demo 直接复制整个 AppState,适用于它现在保存的简单状态。如果以后在 AppState 中加入指针、动态分配资源或只能在本次启动中使用的句柄,应改为只保存恢复页面真正需要的数据。
功能验证
重新编译并烧录 Sticky_dashboard_demo:
idf.py build
idf.py -p PORT flash monitor
可以按下面的顺序验证本篇介绍的功能:
- 切换到 Home,确认页面显示四阶灰度且不显示 RTC 时间。
- 切换到 Sensor、Battery、Note、IMU 或 Microphone,确认页面使用纯黑白画面并显示 RTC 时间。
- 在非 Home 页面保持运行到下一个整分钟,观察状态栏时间变化,并在串口中找到
Partially refreshing RTC time。 - 在 Home 和其他页面分别短按 AI,确认当前页面重新绘制,并执行与页面匹配的全刷。
- 长按 AI 键约两秒,听到蜂鸣提示后松开按键。
- 等待屏幕显示 Deep Sleep 页面,确认设备进入睡眠后画面仍然保留。
- 再次按下 AI 键,确认设备重新启动、恢复到睡眠前的页面,并使用对应的全刷方式。
正常唤醒时,串口中会出现类似日志:
I (...) sticky_power: Woke from deep sleep by AI button
I (...) app: Restored ... Page state after deep sleep
常见问题
-
灰阶页面执行局刷后颜色发生变化
黑白局刷会将画面转换为黑白比较帧,不适合包含灰阶内容的页面。灰阶页面应使用四阶灰度全刷,并避免执行 RTC 黑白局刷。
-
新增黑白页面后,浅灰线条或文字消失
黑白刷新会将四阶颜色转换为黑白,浅灰内容可能被转换为白色。使用黑白刷新方式的页面应只使用
GrayLevel::Black和GrayLevel::White。 -
连续局刷后出现轻微残影
局刷适合更新时间等小范围内容,多次局刷后可以在合适的时机执行一次全刷。当前 Demo 中,短按 AI 会按照当前页面的刷新策略重新绘制完整画面。
-
进入 Deep Sleep 时出现触摸错误或立即唤醒
进入睡眠前应先停止触摸轮询,再让电子纸进入休眠,并等待 AI 键完全释放后配置低电平唤醒。否则可能出现 GT911 读取错误,或因按键仍被按下而立即唤醒。
-
Deep Sleep 唤醒后没有恢复原页面
唤醒后 ESP32-S3 会重新执行
app_main()。睡眠前需要将页面状态保存到 RTC 保留内存,启动后重新初始化硬件、恢复AppState,再通过refresh_current_page()使用该页面对应的刷新方式。
开发小结
三篇教程围绕同一个 Demo,介绍了 Sticky ESP-IDF 开发的三条主要路径:
开发基础:工程结构 -> AppState -> 页面接入
页面与外设:Device -> 数据 / 事件 -> 页面行为
刷新与低功耗:刷新方式 -> RTC 局刷 -> Deep Sleep -> 状态恢复
继续开发自己的应用时,可以沿用相同的分层方式:由 Device 模块访问硬件,由 App 层管理状态和事件,由 Page Renderer 绘制 Canvas,再由 Display 层统一处理画面旋转和电子纸刷新。
基于这一结构,新增常规的数据展示功能时,通常只需沿现有结构扩展 Device、app_data、AppState 和 Page Renderer,即可复用已有的显示刷新与唤醒流程,避免页面、外设和显示控制相互耦合。