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::BlackGrayLevel::White 的页面;需要保留四阶灰度效果的 Home 则使用四阶灰度全刷。

黑白局刷

RTC 时间每分钟只变化一次,而且只占状态栏中的一小块区域。Sensor、Battery、Note、IMU 和 Microphone 页面会显示状态栏时间;分钟变化时,Demo 只修改时间区域,再调用:

sticky_display_refresh_partial();

Home 不显示状态栏时间,也不会执行 RTC 局刷。这样可以让 Home 始终保留完整的四阶灰度效果,同时让其他页面继续使用高效的分钟局刷。

局刷适合小范围的黑白内容更新;切换完整页面、更新大面积内容或显示四阶灰度时,使用对应的全刷方式可以获得更加一致的显示效果。

Tip

选择刷新方式时,可以先判断画面是否需要灰阶、变化范围是否足够小,以及变化内容是否为纯黑白。Demo 只在黑白页面上局刷状态栏时间,其他页面更新仍使用与当前页面匹配的全刷方式。

RTC 时间局刷

非 Home 页面的 RTC 时间更新把前两篇介绍的 RTC、AppStateAppEvent、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 数据,而不是睡眠前保存的旧时间。

Note

Demo 直接复制整个 AppState,适用于它现在保存的简单状态。如果以后在 AppState 中加入指针、动态分配资源或只能在本次启动中使用的句柄,应改为只保存恢复页面真正需要的数据。

功能验证

重新编译并烧录 Sticky_dashboard_demo

idf.py build
idf.py -p PORT flash monitor

可以按下面的顺序验证本篇介绍的功能:

  1. 切换到 Home,确认页面显示四阶灰度且不显示 RTC 时间。
  2. 切换到 Sensor、Battery、Note、IMU 或 Microphone,确认页面使用纯黑白画面并显示 RTC 时间。
  3. 在非 Home 页面保持运行到下一个整分钟,观察状态栏时间变化,并在串口中找到 Partially refreshing RTC time
  4. 在 Home 和其他页面分别短按 AI,确认当前页面重新绘制,并执行与页面匹配的全刷。
  5. 长按 AI 键约两秒,听到蜂鸣提示后松开按键。
  6. 等待屏幕显示 Deep Sleep 页面,确认设备进入睡眠后画面仍然保留。
  7. 再次按下 AI 键,确认设备重新启动、恢复到睡眠前的页面,并使用对应的全刷方式。

正常唤醒时,串口中会出现类似日志:

I (...) sticky_power: Woke from deep sleep by AI button
I (...) app: Restored ... Page state after deep sleep

常见问题

  1. 灰阶页面执行局刷后颜色发生变化

    黑白局刷会将画面转换为黑白比较帧,不适合包含灰阶内容的页面。灰阶页面应使用四阶灰度全刷,并避免执行 RTC 黑白局刷。

  2. 新增黑白页面后,浅灰线条或文字消失

    黑白刷新会将四阶颜色转换为黑白,浅灰内容可能被转换为白色。使用黑白刷新方式的页面应只使用 GrayLevel::BlackGrayLevel::White

  3. 连续局刷后出现轻微残影

    局刷适合更新时间等小范围内容,多次局刷后可以在合适的时机执行一次全刷。当前 Demo 中,短按 AI 会按照当前页面的刷新策略重新绘制完整画面。

  4. 进入 Deep Sleep 时出现触摸错误或立即唤醒

    进入睡眠前应先停止触摸轮询,再让电子纸进入休眠,并等待 AI 键完全释放后配置低电平唤醒。否则可能出现 GT911 读取错误,或因按键仍被按下而立即唤醒。

  5. 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_dataAppState 和 Page Renderer,即可复用已有的显示刷新与唤醒流程,避免页面、外设和显示控制相互耦合。

Community 社区支持

Need more help? 还需要帮助?

Join our community, ask questions, or reach out to Seeed Studio technical support. 加入社区提问交流,或直接联系 Seeed Studio 技术支持。