ESP-IDF 页面与外设
第一篇介绍了 Sticky 的工程结构,以及数据如何通过 AppState 进入页面。本篇继续使用同一个综合开发 Demo(Sticky_dashboard_demo),通过几类真实外设说明数据何时读取、输入如何触发应用行为,以及结果如何显示到电子纸。
本篇主要介绍:
- 区分数据型外设和输入型外设
- 复用 Board 层管理的共享总线和供电资源
- 将多个硬件状态组合到同一个页面
- 从 MicroSD 加载页面内容
- 通过后台事件和用户操作更新页面
- 根据使用场景选择进入页面时读取、后台监测或按需采集
开始前,请先完成 ESP-IDF 开发基础,确认该 Demo 能够正常编译、烧录和切换页面。
本篇基于 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/GPIO1 | BQ27220、PCF8563、SHT40、LSM6DS3TR-C | 复用 board_sensor_i2c_bus() |
| I2C0,GPIO2/GPIO3 | GT911 Touch | 由 sticky_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 总线。
如果某个 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
准备文本文件
- 将 MicroSD 卡格式化为兼容的 FAT 文件系统。
- 在卡的根目录新建
TEST.TXT。 - 写入用于测试的纯文本并保存。
- 将 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、AppState 或 AppEvent。
常见问题
-
插入 MicroSD 后电子纸无法刷新
MicroSD 和电子纸共用 SPI2。SD 读取结束时不能释放整条 SPI 总线。应复用 Display 已初始化的 SPI2,完成文件读取并卸载文件系统后,再执行电子纸刷新。
-
屏幕初始化后,传感器 I2C 持续通信失败
未使用的 SPI 数据引脚如果保持默认值
0,SPI 会错误占用传感器 I2C 使用的 GPIO0。初始化spi_bus_config_t时,必须将所有未使用的数据引脚明确设置为-1。 -
触摸滑动方向与页面切换方向相反
sticky_touch已根据屏幕安装方向完成坐标转换。页面和 App 层应直接使用转换后的滑动事件,不要再次旋转或翻转触摸坐标。 -
按键或后台事件触发后出现卡顿
GPIO 回调和后台任务中不应直接刷新电子纸、控制蜂鸣器或修改页面。回调只发送
AppEvent,耗时操作统一交给 App 事件循环处理。 -
页面刷新时外设访问阻塞
Page Renderer 中不要直接读取传感器或挂载 MicroSD。硬件访问放在 Device 模块,由 App 层更新
AppState;页面只读取状态并绘制 Canvas。
下一步
下一篇将继续使用同一个事件循环和 Display 模块,说明不同场景为什么分别选择四阶灰度全刷、黑白刷新或局部刷新,以及 AI 长按进入深度睡眠后的唤醒恢复流程。
继续阅读 ESP-IDF 刷新与低功耗。