ESP-IDF 开发基础
reTerminal Sticky 是一款适合长期展示天气、日程、留言和轻量数据的电子纸信息终端。通过 ESP-IDF,你可以使用它的电子纸屏幕、触摸屏、实体按键和板载外设,开发自己的常驻信息页面。
本篇将带你运行综合开发 Demo(Sticky_dashboard_demo),熟悉如何基于 ESP-IDF 驱动 Sticky 的屏幕、板载外设和交互功能。示例工程提供了一套完整的硬件调用和页面开发参考,你可以在此基础上创建自己的页面、接入板载外设,并进一步开发自己的 Sticky 项目。
刷写示例工程会替换 Sticky 当前运行的固件。请确认设备中没有需要保留的内容,再继续操作。
分享你的项目
如果你已经基于 reTerminal Sticky 完成了自己的项目,欢迎将它贡献到 Sticky Playground,与更多用户分享你的创意。感谢每一位参与共建的开发者。
请按照仓库 README 中的贡献指南准备项目并提交 Pull Request。项目通过审核并发布后,其他用户便可以在 Sticky Playground 中安装和体验。
开发环境
开始前,请准备:
- reTerminal Sticky
- 支持数据传输的 USB-C 数据线
- Windows、Linux 或 macOS 电脑
- ESP-IDF v5.4
Sticky_dashboard_demo 已在 ESP-IDF v5.4 上完成开发和实机验证。尚未安装开发环境时,请先按照乐鑫的 ESP32-S3 ESP-IDF v5.4 入门指南 完成安装。
安装完成后,在 ESP-IDF 终端中运行:
idf.py --version
输出中应包含:
ESP-IDF v5.4
只能充电的 USB-C 线可以为 Sticky 供电,但电脑无法通过它识别串口。如果设备能够充电却没有出现串口,请先更换支持数据传输的线缆。
Sticky 的主要开发资源如下:
| 硬件 | 在示例中的用途 |
|---|---|
| 3.97 英寸、800 × 480 四阶灰度电子纸 | 绘制和长期保留页面内容 |
| 电容式触摸屏 | 识别左右滑动 |
| UP、DOWN 和 AI 按键 | 切页、刷新和进入深度睡眠 |
| SHT40、RTC、电池计量和 IMU | 提供环境、时间、电量和姿态数据 |
| MicroSD 和 PDM 麦克风 | 读取文本和采集音频 |
| 蜂鸣器 | 提供操作反馈 |
更完整的规格和硬件关系,请先阅读硬件概览。
运行 Demo
本系列提供两份示例工程:
| 工程 | 名称 | 适用场景 |
|---|---|---|
Sticky_peripheral_demo | 外设最小控制 Demo | 提供独立、精简的板载外设控制示例,适合开发者直接参考和复用 |
Sticky_dashboard_demo | 综合开发 Demo | 整合页面、外设、交互和低功耗功能,用于跟随本系列 Wiki 学习完整开发流程 |
如果你已经熟悉 ESP-IDF,希望直接查看某个板载外设的控制方式,可以使用 Sticky_peripheral_demo。其中的示例相互独立,便于直接参考或移植到自己的项目中。
如果你希望跟随本系列 Wiki 从页面显示、外设接入一路学习到局部刷新和低功耗,请使用 Sticky_dashboard_demo。后续三篇教程中的代码、工程结构和页面效果均以该 Demo 为准。
下载并解压 Sticky_dashboard_demo,在包含顶层 CMakeLists.txt 的目录中打开 ESP-IDF v5.4 终端。
第一次编译时,设置目标芯片:
idf.py set-target esp32s3
编译工程:
idf.py build
看到 Project build complete 后,用 USB-C 数据线连接 Sticky。将 PORT 替换为设备串口,然后烧录并打开串口监视器:
idf.py -p PORT flash monitor
例如,Windows 串口为 COM5 时:
idf.py -p COM5 flash monitor
Linux 通常使用 /dev/ttyUSB0 或 /dev/ttyACM0,macOS 通常使用 /dev/cu.usbserial-* 或 /dev/cu.usbmodem-*。按 Ctrl+] 可以退出串口监视器。
启动后,串口会出现:
I (...) sticky_dashboard: Starting reTerminal Sticky Dashboard Demo
屏幕完成第一次刷新后显示 Home 页面。使用 UP/DOWN 按键或左右滑动,可以依次查看:
Home -> Sensor -> Battery -> Note -> IMU -> Microphone -> Home
Home 用于展示四阶灰度,不显示 RTC 时间;其他五个页面使用纯黑白画面,并在状态栏显示 RTC 时间。
电子纸全屏刷新会经过完整的像素更新过程。刷新完成后,页面将稳定显示新的内容。
工程结构
Sticky_dashboard_demo 将硬件访问、应用逻辑和页面绘制分开管理:
Sticky_dashboard_demo/
|-- components/ 外部组件和底层硬件组件
`-- main/
|-- main.cpp 初始化与主事件循环
|-- pin_config.h 引脚和设备地址
|-- board/ 供电、GPIO 和共享总线
|-- devices/ 板载外设访问
|-- app/ 状态、数据、事件和页面导航
|-- pages/ 页面绘制
`-- ui/ Canvas、字体和公共状态栏
页面显示硬件数据时,数据按下面的方向传递:
硬件
↓
devices 读取设备
↓
app_data 更新应用数据
↓
AppState 保存页面状态
↓
pages 绘制页面
↓
Canvas 生成完整画面
↓
display 刷新电子纸
各目录的职责如下:
| 目录 | 负责内容 |
|---|---|
board/ | 初始化板级供电和多个设备共用的总线 |
devices/ | 初始化、读取或控制具体硬件 |
app/ | 保存状态、更新数据、处理事件和选择页面 |
pages/ | 根据 AppState 绘制页面 |
ui/ | 提供 Canvas、字体和公共状态栏 |
这种结构可以避免硬件通信、数据处理和页面绘制相互耦合。开发新功能时,通常只需要接入数据来源、扩展 AppState 并创建页面,不必重新实现现有的显示、输入和刷新框架。
页面绘制与屏幕刷新
页面 Renderer(页面绘制函数)只负责把内容写入 Canvas。绘制完成后,再由 App 层统一刷新电子纸:
render_current_page(canvas, state);
ESP_RETURN_ON_ERROR(
refresh_current_page(state), "app", "refresh current page");
refresh_current_page() 会根据 state.current_page 选择刷新方式:Home 使用四阶灰度全刷,其他页面使用黑白全刷。因此,页面文件只负责绘制 Canvas;Display 初始化和刷新由现有的 Device 与 App 流程统一完成。
Canvas 使用 800 × 480 的横屏逻辑坐标,原点位于左上角。屏幕旋转已经由 sticky_display 处理,页面中不需要再次转换坐标。
数据流与页面显示
Sensor 页面展示了一条完整的数据链路:SHT40 读取温湿度,应用保存数据,页面再把结果显示出来。
SHT40
↓
sticky_sht40_read()
↓
update_environment()
↓
AppState.environment
↓
sensor_page_render()
↓
Canvas
读取传感器
main/devices/sticky_sht40.* 封装 SHT40 的硬件访问。初始化时,它使用 Board 层已经创建的传感器 I2C 总线:
ESP_ERROR_CHECK(sticky_sht40_init(board_sensor_i2c_bus()));
RTC、电池计量和 IMU 也使用这条共享总线,因此不需要为每个设备重复创建 I2C 总线。
更新应用状态
main/app/app_state.h 中的 EnvironmentState 保存页面需要的温湿度和读取状态:
struct EnvironmentState {
float temperature_c = 0.0F;
float humidity_percent = 0.0F;
bool valid = false;
esp_err_t error = ESP_ERR_INVALID_STATE;
};
main/app/app_data.cpp 调用 Device API,并把读取结果写入 AppState。核心关系如下:
Sht40Reading reading = {};
const esp_err_t result = sticky_sht40_read(reading);
state.environment.error = result;
state.environment.valid = result == ESP_OK;
读取成功后,函数继续保存温度和湿度;读取失败时,valid 保持为 false,并通过错误码和串口日志帮助定位问题。
绘制传感器数据
main/pages/sensor_page.cpp 只读取状态。数据有效时显示温度;读取失败时在相同位置显示 N/A:
canvas.draw_text(76, 235, "Temperature:", 3, GrayLevel::Black);
if (state.environment.valid) {
std::snprintf(value, sizeof(value), "%.1f C",
static_cast<double>(state.environment.temperature_c));
canvas.draw_text(500, 235, value, 3, GrayLevel::Black);
} else {
canvas.draw_text(500, 235, "N/A", 3, GrayLevel::Black);
}
进入 Sensor 页面时,App 层按顺序更新数据、绘制页面并刷新屏幕。页面本身不直接读取 SHT40。
这条链路适用于大多数数据页面:先确定数据来自哪里,再通过 AppState 交给页面显示。第二篇 Wiki 将继续使用 Battery、MicroSD、IMU 和 Microphone 展示不同类型的外设数据。
添加 Hello 页面
下面在现有页面循环末尾加入一个 Hello 页面。它不访问硬件,只用于完成一次完整的页面接入。
完成后,页面顺序变为:
Home -> Sensor -> Battery -> Note -> IMU -> Microphone -> Hello -> Home
步骤 1:创建页面文件
新建 main/pages/hello_page.h:
#pragma once
class Canvas;
struct AppState;
void hello_page_render(Canvas &canvas, const AppState &state);
新建 main/pages/hello_page.cpp:
#include "hello_page.h"
#include "app_state.h"
#include "canvas.h"
#include "page.h"
void hello_page_render(Canvas &canvas, const AppState &state)
{
page_draw_layout(canvas, state, "Hello Sticky", "My First Page");
canvas.draw_text(76, 250, "ESP-IDF Ready", 3, GrayLevel::Black);
canvas.draw_line(76, 310, 718, 310, GrayLevel::Black);
}
page_draw_layout() 会绘制与其他黑白页面一致的边框、状态栏和标题。Hello 页面只需要补充自己的内容。
Demo 只将 Home 定义为四阶灰度页面,因此新增的 Hello 页面会自动使用黑白全刷。新增常规页面时应使用 GrayLevel::Black 和 GrayLevel::White;如果页面确实需要四阶灰度,还需要在 page_uses_gray4() 中加入对应的 PageId。
步骤 2:增加页面 ID
在 main/app/app_state.h 的 PageId 末尾加入 Hello:
enum class PageId {
Home,
Sensor,
Battery,
Note,
Imu,
Microphone,
Hello,
};
Hello 页面没有新的数据,因此不需要修改 AppState。如果页面需要显示外设数据,应参考上一节增加对应的状态结构和数据更新函数。
步骤 3:注册页面
在 main/app/app.cpp 中包含页面头文件:
#include "hello_page.h"
在 page_name() 中加入页面名称:
case PageId::Hello:
return "Hello";
在 render_current_page() 中关联 Renderer:
case PageId::Hello:
renderer = hello_page_render;
break;
步骤 4:加入页面导航
在 select_next_page() 中,让 Microphone 进入 Hello,再从 Hello 返回 Home:
case PageId::Microphone:
state.current_page = PageId::Hello;
break;
case PageId::Hello:
state.current_page = PageId::Home;
break;
在 select_previous_page() 中补上反向关系:
case PageId::Home:
state.current_page = PageId::Hello;
break;
case PageId::Hello:
state.current_page = PageId::Microphone;
break;
步骤 5:加入构建系统
在 main/CMakeLists.txt 的页面源文件列表中加入:
"pages/hello_page.cpp"
保存修改后重新编译和烧录:
idf.py build
idf.py -p PORT flash monitor
进入 Hello 页面时,屏幕应显示 My First Page 和 ESP-IDF Ready。
继续向下一页切换会返回 Home,向上一页切换也应保持完整切页过程。
到这里,你已经完成了一个页面的完整接入:
创建页面文件
↓
增加 PageId
↓
注册 Renderer
↓
加入页面导航
↓
更新 CMakeLists.txt
↓
编译并在设备上验证
应用扩展流程
创建新的信息页面时,可以按照下面的顺序进行:
- 确定页面要显示的数据,以及工程中是否已有对应的 Device API。
- 没有可用接口时,在
devices/中添加硬件访问;已有接口时直接复用。 - 在
AppState中保存页面需要的数据、有效状态和错误码。 - 在
app_data中读取数据并更新状态。 - 创建 Page Renderer,并将页面加入
PageId、页面路由和导航顺序。 - 将新增的
.cpp加入main/CMakeLists.txt,编译后在设备上验证显示和异常状态。
扩展应用时,各模块保持以下分工:
- 页面只读取
AppState并绘制 Canvas,不直接访问硬件。 - Device 模块只负责硬件访问,不绘制 UI。
- 共享的总线和供电资源由 Board 层管理。
- 页面绘制完成后,由 App 层统一刷新电子纸。
常见问题
-
新增页面后出现
undefined reference页面
.cpp没有加入main/CMakeLists.txt,或函数声明与实现不一致。确认源文件已加入SRCS,并检查函数名称和参数后重新构建。 -
新增页面可以编译,但无法切换进入
仅添加
PageId或 Renderer 还不完整。确认render_current_page()已注册页面,并在正向和反向导航中加入对应的PageId。 -
页面内容的方向与实机不一致
页面应始终使用左上角为原点的 800 × 480 逻辑坐标。物理屏幕所需的 180° 旋转由 Display 层统一处理,不要在 Page Renderer 中再次旋转坐标。
-
页面文字显示为
?当前内置点阵字库支持可打印 ASCII。中文、
℃等未收录字符会显示为?,使用前需要补充对应字形,或改用字库已支持的字符。 -
工程换到另一台电脑后构建失败
build/中包含本机生成的路径和配置。切换开发环境后,先确认 ESP-IDF v5.4 和 ESP32-S3 目标正确;仍有错误时,只删除自动生成的build/目录并重新构建。
下一步
本篇完成了两条基础开发路径:
页面接入:页面文件 -> PageId -> Renderer -> 页面导航 -> CMake
数据显示:Device -> app_data -> AppState -> Page -> Canvas -> Display
下一篇将在同一个 Demo 中继续介绍:
- Battery、MicroSD、IMU 和 Microphone 的数据读取与页面显示
- Button 和 Touch 如何通过
AppEvent触发切页和刷新 - 不同外设如何复用 Board 层的共享资源
继续阅读 ESP-IDF 页面与外设。