ESP-IDF 页面与外设

第一篇介绍了 Sticky 的工程结构,以及数据如何通过 AppState 进入页面。本篇继续使用同一个综合开发 Demo(Sticky_dashboard_demo),通过几类真实外设说明数据何时读取、输入如何触发应用行为,以及结果如何显示到电子纸。

本篇主要介绍:

  • 区分数据型外设和输入型外设
  • 复用 Board 层管理的共享总线和供电资源
  • 将多个硬件状态组合到同一个页面
  • 从 MicroSD 加载页面内容
  • 通过后台事件和用户操作更新页面
  • 根据使用场景选择进入页面时读取、后台监测或按需采集

开始前,请先完成 ESP-IDF 开发基础,确认该 Demo 能够正常编译、烧录和切换页面。

Note

本篇基于 Demo 中已有的 Device 模块,介绍如何调用外设并接入 Sticky 应用,不展开芯片寄存器和底层驱动开发。如果需要支持新的硬件,可以参考 main/devices/ 中现有模块的接口形式继续扩展。

外设接入

不同外设的数据产生方式和更新时间不同。Demo 根据实际使用场景,将它们分为以下几种接入方式:

接入方式Demo 中的示例使用方式
切换或刷新时更新SHT40、Battery、Charger进入对应页面或手动刷新时,由 App 层读取数据
文件内容加载MicroSD需要内容时挂载、读取并卸载
后台状态监测IMU后台检测稳定状态变化并发送事件
用户触发采集Microphone用户明确操作后执行一次采样
输入事件Button、Touch将按键或触摸操作转换为 AppEvent

数据型外设继续沿用第一篇介绍的路径:

Device -> app_data -> AppState -> Page -> Canvas -> Display

输入型外设不向页面提供显示数据,而是触发应用行为:

Button / Touch -> AppEvent -> handle_app_event() -> Page / Display

接下来将通过 Button、Touch、Battery、MicroSD、IMU 和 Microphone,分别说明这些接入方式的实际用法。开发自己的功能时,可以先判断数据在何时产生,再选择最接近的模式。

复用 Board 层资源

Sticky 的部分外设共用通信总线,外设电源也由软件按需控制。board_init() 负责保持系统供电、设置 MicroSD 的默认状态,并创建共享的传感器 I2C 总线。随后,各 Device 模块复用这些资源完成初始化。

Demo 中的主要资源关系如下:

资源外设管理方式
I2C1,GPIO0/GPIO1BQ27220、PCF8563、SHT40、LSM6DS3TR-C复用 board_sensor_i2c_bus()
I2C0,GPIO2/GPIO3GT911 Touchsticky_touch 单独创建
SPI2 信号线E-paper、MicroSD使用不同的 CS 引脚,共享同一个 SPI2 Host
外设电源E-paper、Touch、MicroSD、Microphone由对应模块按使用状态控制

board_init() 只创建一次传感器 I2C 总线:

i2c_master_bus_config_t sensor_bus_config = {};
sensor_bus_config.i2c_port = I2C_NUM_1;
sensor_bus_config.sda_io_num = static_cast<gpio_num_t>(PIN_SENSOR_SDA);
sensor_bus_config.scl_io_num = static_cast<gpio_num_t>(PIN_SENSOR_SCL);
sensor_bus_config.clk_source = I2C_CLK_SRC_DEFAULT;
sensor_bus_config.glitch_ignore_cnt = 7;
sensor_bus_config.flags.enable_internal_pullup = 1;
result = i2c_new_master_bus(&sensor_bus_config, &s_sensor_i2c_bus);

需要传感器总线的 Device 模块复用同一个 I2C 总线句柄:

ESP_ERROR_CHECK(sticky_battery_init(board_sensor_i2c_bus()));
ESP_ERROR_CHECK(sticky_sht40_init(board_sensor_i2c_bus()));
ESP_ERROR_CHECK(sticky_rtc_init(board_sensor_i2c_bus()));
ESP_ERROR_CHECK(sticky_imu_init(board_sensor_i2c_bus()));

开发新功能前,先检查现有 Board 和 Device 模块是否已经提供所需资源。不要在页面中创建总线,也不要为 GPIO0/GPIO1 上的每个传感器分别创建 I2C 总线。

Tip

如果某个 I2C 外设初始化失败,先确认 board_init() 已经完成,并检查它是否复用了 board_sensor_i2c_bus()。同一组引脚上重复创建 I2C 总线通常会导致资源冲突。

按键与触摸交互

第一篇创建的 Hello 页面不需要单独处理按键和触摸。只要页面已经加入导航顺序,现有输入模块就能通过统一事件切换到它。

Demo 将输入映射为以下事件:

输入AppEvent应用行为
UP 短按PreviousPage切换到上一页
DOWN 短按NextPage切换到下一页
向右滑动PreviousPage切换到上一页
向左滑动NextPage切换到下一页
AI 短按RefreshPage重新读取并刷新当前页面
AI 长按约两秒EnterDeepSleep进入深度睡眠,第三篇介绍

按键事件

sticky_buttons_init() 将三个按键分别注册到已有事件:

esp_err_t result = create_button(
    PIN_BTN_UP, AppEvent::PreviousPage, &s_up_button);
if (result != ESP_OK) {
    return result;
}
result = create_button(
    PIN_BTN_DOWN, AppEvent::NextPage, &s_down_button);
if (result != ESP_OK) {
    return result;
}
return create_button(PIN_BTN_OK,
                     AppEvent::RefreshPage,
                     &s_refresh_button,
                     true,
                     AppEvent::EnterDeepSleep);

按键回调只把事件放入队列:

void button_click_callback(void *button_handle, void *user_data)
{
    (void)button_handle;
    const auto event = static_cast<AppEvent>(
        reinterpret_cast<uintptr_t>(user_data));
    app_event_post(event);
}

触摸事件

GT911 的坐标转换在 sticky_touch 内完成。释放手指后,模块只判断是否形成明确的水平滑动:

if (horizontal_distance < kMinimumSwipeDistance ||
    horizontal_distance <= vertical_distance * 2) {
    return false;
}

event = delta_x < 0 ? AppEvent::NextPage : AppEvent::PreviousPage;
return true;

水平移动距离至少为 120 个逻辑像素,并且必须大于垂直移动距离的两倍。单击、短移动和主要为垂直方向的手势不会触发切页。

应用事件处理

主循环从队列取出事件,并统一交给 handle_app_event()

while (true) {
    AppEvent event;
    if (!app_event_wait(event, portMAX_DELAY)) {
        continue;
    }

    const esp_err_t result = handle_app_event(*canvas, state, event);
    if (result != ESP_OK) {
        ESP_LOGE(kTag, "App event failed: %s", esp_err_to_name(result));
    }
}

App 层负责修改 PageId、更新页面数据、调用 Renderer 并刷新屏幕。页面切换或手动刷新完成后,再调用 sticky_buzzer_beep() 提供提示音。输入模块和页面都不直接控制这套流程。

数据读取与显示

本节使用 Battery 和 Note 页面介绍两种数据接入方式:Battery 将多个硬件状态组合到同一页面,Note 则从 MicroSD 加载外部文件内容。

电池与充电状态

第一篇中的 Sensor 页面主要展示单一传感器数据。Battery 页面进一步说明如何将两个硬件结果组合到同一个状态中:

BQ27220 -> Battery percent ─┐
                            ├-> AppState.battery -> Battery Page
External power -> Charging ─┘

相关代码位于:

main/devices/sticky_battery.*
main/devices/sticky_charger.*
main/app/app_state.h
main/app/app_data.cpp
main/pages/battery_page.*

更新电池状态

BatteryState 分别记录电量和充电状态是否有效:

struct BatteryState {
    int percent = 0;
    bool valid = false;
    esp_err_t error = ESP_ERR_INVALID_STATE;
    bool charging = false;
    bool charging_valid = false;
    esp_err_t charging_error = ESP_ERR_INVALID_STATE;
};

update_battery() 依次读取 BQ27220 和外部电源状态:

void update_battery(AppState &state)
{
    BatteryReading reading = {};
    const esp_err_t battery_result = sticky_battery_read(reading);
    state.battery.error = battery_result;
    state.battery.valid = battery_result == ESP_OK;
    if (battery_result == ESP_OK) {
        state.battery.percent = reading.percent;
    }

    bool charging = false;
    const esp_err_t charger_result = sticky_charger_read(charging);
    state.battery.charging_error = charger_result;
    state.battery.charging_valid = charger_result == ESP_OK;
    if (charger_result == ESP_OK) {
        state.battery.charging = charging;
    }
}

两个结果独立保存。即使电量计读取失败,页面仍然可以显示外部电源状态;反过来也一样。

显示电池状态

Battery 页面在数据有效时计算电量条宽度,否则保留空电池轮廓:

if (state.battery.valid) {
    const int inner_width = battery_width - padding * 2;
    const int fill_width = inner_width * state.battery.percent / 100;
    canvas.fill_rect(battery_x + padding,
                     battery_y + padding,
                     fill_width,
                     battery_height - padding * 2,
                     GrayLevel::Black);
}

插入或拔出 USB-C 后,可以重新进入 Battery 页面或短按 AI,让 App 层再次调用 update_battery()

如果一个页面需要组合多个数据来源,可以沿用这一结构,例如同时显示室内外温度、网络状态与更新时间,或汇总多个传感器的结果。

MicroSD 文件读取

Note 页面展示了文件型数据的接入方式。它从 MicroSD 卡根目录读取 TEST.TXT,再通过 AppState.note 交给页面显示:

MicroSD / TEST.TXT
-> sticky_sdcard_read_text()
-> AppState.note
-> Note Page

准备文本文件

  1. 将 MicroSD 卡格式化为兼容的 FAT 文件系统。
  2. 在卡的根目录新建 TEST.TXT
  3. 写入用于测试的纯文本并保存。
  4. 将 MicroSD 卡插入 Sticky。

例如:

Hello Sticky! Happy coding!!!

NoteState 最多保存 256 字节的文本内容,当前页面最多绘制 6 行,每行最多处理 52 个单字节字符。Demo 按单字节文本绘制,第一次验证时建议使用简短的英文、数字和符号。

SPI2 资源共享

update_note() 只调用 Device API,并保存读取结果:

void update_note(AppState &state)
{
    const esp_err_t result = sticky_sdcard_read_text(
        "TEST.TXT", state.note.text, sizeof(state.note.text));
    state.note.error = result;
    state.note.valid = result == ESP_OK;
    if (result != ESP_OK) {
        ESP_LOGW(kTag, "Note load failed: %s", esp_err_to_name(result));
    }
}

在 Demo 中,电子纸与 MicroSD 使用相同的 SPI2 信号线,但拥有不同的 CS。为避免两者发生资源竞争,sticky_sdcard_read_text() 会先完成文件读取和卸载,再由 App 层执行显示刷新:

检测卡片
-> 打开 MicroSD 电源
-> 挂载文件系统
-> 读取 TEST.TXT
-> 卸载文件系统
-> 释放共享 SPI2 访问
-> 绘制并刷新电子纸

App 层在读取完成后才绘制 Note 页面:

} else if (state.current_page == PageId::Note) {
    // SD is unmounted before render_current_page() refreshes the display.
    update_note(state);
}

进入 Note 页面或在该页面短按 AI,都会重新读取文件。卡片未插入、挂载失败或文件不存在时,页面显示检查 MicroSD 的提示,其他页面仍可正常使用。

状态监测与数据采集

IMU 和 Microphone 的数据都不是在每次进入页面时直接读取。IMU 在后台监测稳定方向变化,Microphone 则等待用户明确触发后采集一次。

方式示例更新时机触发机制
后台状态监测IMU稳定方向变化时OrientationChanged
用户触发采集Microphone在 Microphone 页面短按 AI 时RefreshPage

IMU 状态监测

Battery 和 Note 都是在需要页面内容时主动读取。IMU 则持续监测设备姿态,只在稳定方向发生变化时通知 App:

IMU monitoring task
-> stable orientation changed
-> AppEvent::OrientationChanged
-> update_imu()
-> IMU Page

初始化完成后,main.cpp 启动 IMU 监测任务:

ESP_ERROR_CHECK(sticky_imu_start_monitoring());

监测任务以 100 ms 间隔读取加速度。相同方向连续出现 5 次后,才把它视为稳定状态并发送事件:

if (candidate_count >= kStableSampleCount && candidate != stable) {
    stable = candidate;
    sample.orientation = stable;
    store_state(sample);
    app_event_post(AppEvent::OrientationChanged);
} else {
    sample.orientation = stable;
    store_state(sample);
}

这种稳定判断可以减少轻微晃动造成的频繁页面刷新。

IMU 页面更新

App 层收到方向变化事件后,先检查当前页面:

if (orientation_event && state.current_page != PageId::Imu) {
    return ESP_OK;
}

只有正在显示 IMU 页面时,才读取最新稳定状态并刷新画面。其他页面不会因为后台姿态变化反复刷新。

update_imu() 从 Device 模块取得已经保存的最新状态:

StickyImuState reading = {};
const esp_err_t result = sticky_imu_get_state(reading);
state.imu.error = result;
state.imu.valid = result == ESP_OK;
if (result == ESP_OK) {
    state.imu.orientation = reading.orientation;
}

IMU 页面根据方向绘制箭头;设备平放或方向尚未稳定时,提示将设备竖直放置。后台方向事件不播放蜂鸣器,避免自然移动设备时持续发声。

对于需要持续监测、但只在状态真正变化时更新 UI 的功能,可以沿用相同的事件模式,例如门磁状态、阈值告警或外部中断事件。

读取麦克风数据

麦克风采样不需要持续运行。Demo 只在 Microphone 页面短按 AI 时采集约一秒音频:

AI short press
-> AppEvent::RefreshPage
-> sticky_microphone_capture()
-> AppState.microphone
-> Microphone Page

首次进入 Microphone 页面时不会自动采样,屏幕会提示:

Press AI button to sample

App 层同时检查当前页面和事件类型:

} else if (state.current_page == PageId::Microphone &&
           event == AppEvent::RefreshPage) {
    update_microphone(state);
}

update_microphone() 保存本次采样是否已经执行、是否成功,以及 RMS、Peak 和音量百分比。下面的代码突出采样结果状态:

MicrophoneReading reading = {};
const esp_err_t result = sticky_microphone_capture(reading);
state.microphone.captured = true;
state.microphone.error = result;
state.microphone.valid = result == ESP_OK;

读取成功后,完整实现继续将 reading 中的 RMS、Peak 和音量百分比写入 AppState

Device 模块在采样开始时打开麦克风电源和 PDM 接收通道,结束后立即关闭。页面根据结果绘制音量条、RMS 和 Peak;采样失败时显示错误提示。

对于麦克风这类只需在用户操作后运行的功能,可以采用按需采集。新增类似功能时,由 App 层判断当前页面和触发事件,再调用对应的 Device 接口;页面 Renderer 仍然只负责显示结果。

功能验证

重新编译并烧录 Sticky_dashboard_demo

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

在 Sticky 上依次验证:

  • UP/DOWN 和左右滑动使用相同的页面顺序
  • AI 短按会重新读取并刷新当前页面
  • Battery 页面分别显示电量和外部电源状态
  • Note 页面能够读取 TEST.TXT,文件修改后可以重新加载
  • IMU 页面只在稳定方向变化后更新箭头
  • Microphone 页面只在短按 AI 后采样
  • 单个外设读取失败时,其他页面仍可继续使用

上述五种接入方式最终都由 App 层协调:

切换或刷新时更新:Battery
文件内容加载:MicroSD
后台状态监测:IMU
用户触发采集:Microphone
输入事件:Button / Touch

接入新的外设前,先判断数据由页面触发、后台状态变化还是用户操作产生,再决定需要扩展 Device、AppStateAppEvent

常见问题

  1. 插入 MicroSD 后电子纸无法刷新

    MicroSD 和电子纸共用 SPI2。SD 读取结束时不能释放整条 SPI 总线。应复用 Display 已初始化的 SPI2,完成文件读取并卸载文件系统后,再执行电子纸刷新。

  2. 屏幕初始化后,传感器 I2C 持续通信失败

    未使用的 SPI 数据引脚如果保持默认值 0,SPI 会错误占用传感器 I2C 使用的 GPIO0。初始化 spi_bus_config_t 时,必须将所有未使用的数据引脚明确设置为 -1

  3. 触摸滑动方向与页面切换方向相反

    sticky_touch 已根据屏幕安装方向完成坐标转换。页面和 App 层应直接使用转换后的滑动事件,不要再次旋转或翻转触摸坐标。

  4. 按键或后台事件触发后出现卡顿

    GPIO 回调和后台任务中不应直接刷新电子纸、控制蜂鸣器或修改页面。回调只发送 AppEvent,耗时操作统一交给 App 事件循环处理。

  5. 页面刷新时外设访问阻塞

    Page Renderer 中不要直接读取传感器或挂载 MicroSD。硬件访问放在 Device 模块,由 App 层更新 AppState;页面只读取状态并绘制 Canvas。

下一步

下一篇将继续使用同一个事件循环和 Display 模块,说明不同场景为什么分别选择四阶灰度全刷、黑白刷新或局部刷新,以及 AI 长按进入深度睡眠后的唤醒恢复流程。

继续阅读 ESP-IDF 刷新与低功耗

Community 社区支持

Need more help? 还需要帮助?

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