ESP32+MPU6050手势识别:自制自动转屏番茄钟HUD桌面时钟
在实际嵌入式项目中显示和传感器往往是两个最容易让新手卡住的部分。ESP32 配合 2.8 寸 TFT 屏做桌面时钟是一个很典型的组合屏幕负责信息呈现传感器负责输入交互两者通过状态机串联起来。这篇文章记录了一个名为 Deep Warp HUD 的小项目用 ESP32、2.8 寸 SPI 屏和 MPU6050 做了一台支持番茄钟、手势切换页面、自动转屏的桌面时钟并会拆解核心源码说明每个模块为什么这样写以及实际运行时容易踩到哪些问题。这个项目适合已经写过 Arduino 基础程序、想学习传感器与屏幕怎么配合的开发者。做完之后你会得到一个能复现的 HUD 时钟原型也能理解屏幕绘制、姿态识别、状态机调度这三类问题在嵌入式开发中是怎么组合在一起的。1. 先拆解 Deep Warp HUD 项目的需求和工作链路1.1 这个项目到底做了什么从外观上看这是一个带 2.8 寸彩色屏幕的小盒子屏幕显示当前时间、日期以及番茄钟的剩余时间。与普通时钟不同的是它有两个交互能力第一用户拿起设备左右挥动可以切换屏幕页面第二把设备横过来或者竖过来屏幕会自动跟着旋转方向重新排版显示内容。底层硬件主要包括三部分硬件型号/规格作用主控ESP32 DevKitC V4 或兼容板运行逻辑、驱动屏幕、读取传感器屏幕2.8 寸 SPI TFTILI9341 驱动320x240显示时钟、番茄钟和状态信息传感器MPU6050 六轴传感器I2C 接口检测挥动动作和设备朝向这个项目没有用额外按键做主要交互也没有用触摸屏而是把手势作为输入方式。这样做的原因是想验证一个问题当输入不是按键而是加速度传感器时程序结构需要怎么调整。按键是离散事件按下就是按下手势是连续数据流必须把一段时间的加速度变化翻译成一次事件。这就是手势识别与普通按键处理最大的区别。1.2 从传感器到屏幕的数据流整台设备的数据流可以分成三条链路时间链路ESP32 通过 WiFi 连接 NTP 服务器获取当前时间写入系统 RTC显示层每秒读取一次并绘制。姿态链路MPU6050 以固定频率读取加速度值算法根据加速度判断设备是横着还是竖着同时识别“左右挥动”这个手势。状态链路番茄钟模块维护一个状态机手势触发状态切换界面根据状态和剩余时间决定如何绘制。比较关键的一点是第二条链路。屏的旋转和手势事件都来自同一个传感器数据源但处理逻辑不能混在一起。自动转屏是持续状态判断每 500 毫秒看一次角度就可以了手势是瞬时事件必须捕捉加速度的快速变化。如果混在一个函数里要么手势识别不及时要么屏幕旋转频繁抖动。所以在源码里传感器读取只负责提供数据角度计算和手势判定分别放在独立函数中。2. 开发环境准备与依赖安装2.1 Arduino IDE 配置 ESP32 开发板项目基于 Arduino 框架开发IDE 使用 Arduino IDE 2.x 即可。安装完 IDE 后第一步不是写代码而是把 ESP32 的开发板支持包安装好。在 Arduino IDE 的“文件 - 首选项 - 附加开发板管理器网址”中填入https://espressif.github.io/arduino-esp32/package_esp32_index.json然后在“工具 - 开发板 - 开发板管理器”中搜索esp32安装esp32 by Espressif Systems。安装时间取决于网络环境如果下载很慢可以考虑下载离线安装包后手动放到 Arduino15 目录。离线包方案在社区里很常见适合网络受限的场景。注意ESP32 支持包版本会影响库 API 和编译结果安装完成后在“工具 - 开发板 - ESP32 Arduino”下选择ESP32 Dev Module不要选成ESP32 Wrover Module两者 Flash 配置不同选错后烧录会出现奇怪问题。2.2 安装屏幕库和传感器库本次用到的主要库如下库名用途安装方式TFT_eSPI驱动 ILI9341 屏幕支持旋转和内置字体库管理器搜索安装MPU6050_light简化 MPU6050 数据读取和角度计算库管理器搜索安装WireI2C 通信ESP32 内置无需额外安装TFT_eSPI 是一个很常用的屏幕驱动库它支持多种芯片配置方式不是实例化时传参数而是修改库目录下的User_Setup.h。这一点和很多 Arduino 库不同初次使用容易找不到配置入口。MPU6050_light 适合快速上手它封装了加速度和陀螺仪的读取也提供了getAngleX、getAngleY等接口。如果后续要存储配置或者把开机图片放入文件系统再安装 SPIFFS 插件。当前版本 Arduino IDE 2.x 已经内置了 SPIFFS 上传功能不一定需要额外插件旧版 IDE 才需要单独安装。2.3 验证开发环境是否可用先不要写项目代码直接用 IDE 自带的 Blink 示例验证开发板识别和烧录通道是否正常。选择ESP32 Dev Module后在“工具 - 端口”中选择 ESP32 对应的串口。上传一个点灯程序如果串口输出乱码或无法识别通常是 CH340/CP2102 驱动问题。ESP32 DevKitC V4 部分版本使用 CP2102部分使用 CH340建议去芯片厂商页面装对应驱动。3. 硬件接线与引脚映射3.1 2.8 寸 SPI 屏幕接线市面上常见的 2.8 寸 TFT 模块使用 ILI9341 驱动接口是 4 线 SPI加上 CS、DC、RST 和背光控制。常见接法如下屏幕引脚功能ESP32 DevKitC GPIOVCC电源 3.3V 或 5V按模块丝印3.3V 或 5VGND地GNDCS片选GPIO5RESET/RST复位GPIO4DC/RS数据/命令选择GPIO2SDI/MOSISPI 数据输入GPIO23SCK/SCLKSPI 时钟GPIO18LED/BL背光控制GPIO15 或接 3.3VSDO/MISO可选用于读取屏幕数据GPIO19可不接这里使用 ESP32 的 VSPI 默认引脚MOSI 是 GPIO23、SCLK 是 GPIO18。使用默认引脚的好处是 TFT_eSPI 配置简单不用额外初始化 SPI 对象。3.2 MPU6050 接线MPU6050 使用 I2C 通信接线比 SPI 简单传感器引脚ESP32 DevKitC GPIOVCC3.3VGNDGNDSDAGPIO21SCLGPIO22AD0GNDINT可不接本项目不依赖中断AD0 引脚决定 I2C 地址。AD0 接 GND 时地址是 0x68接 3.3V 时地址是 0x69。在初始化代码中如果读取不到数据第一件事就是检查 AD0 接法。3.3 电源和走线注意事项屏幕背光电流较大规格不同差异也大。如果屏幕 VCC 接 3.3V而 ESP32 板载 3.3V 稳压器输出能力不足会导致屏幕闪烁或白屏。建议用 USB 5V 给 ESP32 供电屏幕 VCC 按模块要求接 5V 或 3.3V。不要试图用 ESP32 的 GPIO 同时驱动屏幕背光和传感器电流不够时会出现各种随机问题。学习阶段用面包板完全可以但要注意杜邦线过长会引入干扰。SPI 时钟频率偏高时尤其明显后面配置库参数时会说到。4. 屏幕驱动与显示层准备4.1 修改 TFT_eSPI 的 User_Setup.hTFT_eSPI 在第一次使用时需要修改配置。打开库目录下的User_Setup.h取消对应芯片和引脚的注释。关键配置项如下#define ILI9341_DRIVER #define TFT_MISO 19 #define TFT_MOSI 23 #define TFT_SCLK 18 #define TFT_CS 5 #define TFT_DC 2 #define TFT_RST 4 #define TOUCH_CS 21配置完成后把User_Setup.h保存再编译一个 TFT_eSPI 自带的示例比如TFT_Print_Test能正常显示就说明配置正确。配置项含义建议值错误表现ILI9341_DRIVER启用 ILI9341 驱动启用白屏或花屏TFT_MOSI/TFT_SCLKSPI 数据与时钟23/18无显示TFT_CS片选引脚5无法选中屏幕TFT_DC命令/数据引脚2屏幕输出乱码SPI_FREQUENCYSPI 通信频率10000000 左右频率过高会花屏SPI_FREQUENCY默认可能是 27000000甚至更高。2.8 寸模块在杜邦线连接下27MHz 偶尔会花屏把频率降到 10000000 或者 20000000 通常能解决。学习阶段没有必要追求最高刷新率稳定优先。4.2 旋转模型setRotation 与坐标系变化TFT_eSPI 的旋转通过setRotation(0~3)控制。0 和 2 是竖屏1 和 3 是横屏。旋转之后tft.width()和tft.height()会互换绘制坐标原点始终在左上角。这一点非常关键因为自动转屏后不能只调用一次setRotation就结束必须按新的宽高重新布局。rotation方向widthheight0竖屏2403201横屏顺时针 90 度3202402竖屏倒置2403203横屏逆时针 90 度320240代码中每次需要绘制界面时统一走一个drawCurrentView()函数这个函数内部先读取tft.width()和tft.height()动态计算布局这样可以避免写死坐标导致的越界问题。4.3 字体和文本绘制ILI9341 默认支持 ASCII 字符。显示大号时间数字时TFT_eSPI 内置了 7 段数码管风格的字体例如7SEG字体构造函数是tft.loadFont(SEVEN_SEG_NUM_28), 也可以使用库内置的tft.setTextFont(7)。需要注意的是加载字体需要调用loadFont不需要时要unloadFont否则反复切换界面可能内存不足。5. 手势识别与自动转屏核心逻辑5.1 读取 MPU6050 加速度MPU6050_light 库的初始化在setup()中完成#include Wire.h #include MPU6050_light.h MPU6050 mpu(Wire); void setup() { Wire.begin(21, 22); mpu.begin(); mpu.calcOffsets(); // 校准零偏需要设备静止 }mpu.begin()内部可能因为 I2C 地址错误返回失败但MPU6050_light在部分版本中失败后不会明显报错只是后续所有读数都为 0。建议在初始化后检查mpu.getError()或者直接打印mpu.getAccX()验证是否有有效数据。主循环中用固定频率调用mpu.update()数据才会更新void loop() { mpu.update(); float ax mpu.getAccX(); // 单位 g float ay mpu.getAccY(); float az mpu.getAccZ(); }5.2 手势判定把加速度变化变成事件挥手是一个快速左右摆动的动作。从加速度波形看会先出现一个正峰值紧接着出现一个负峰值。因此可以做一个简单的状态机#define GESTURE_THRESHOLD 0.6f #define GESTURE_TIMEOUT 500 enum GestureState { GESTURE_IDLE, GESTURE_POSITIVE, GESTURE_NEGATIVE }; GestureState gestureState GESTURE_IDLE; unsigned long lastGestureTime 0; bool detectSwipe(float ax, unsigned long now) { if (now - lastGestureTime GESTURE_TIMEOUT) { gestureState GESTURE_IDLE; } switch (gestureState) { case GESTURE_IDLE: if (ax GESTURE_THRESHOLD) { gestureState GESTURE_POSITIVE; lastGestureTime now; } break; case GESTURE_POSITIVE: if (ax -GESTURE_THRESHOLD) { gestureState GESTURE_NEGATIVE; lastGestureTime now; return true; } break; case GESTURE_NEGATIVE: // 等待回到 IDLE if (fabs(ax) 0.2f) { gestureState GESTURE_IDLE; } break; } return false; }这段代码的关键点是阈值和时间窗。阈值不能设得太小否则设备放在桌面上轻微震动也会误触发时间窗也不能太长否则一次挥动可能被识别成两次。5.3 自动转屏的实现思路自动转屏要根据设备姿态判断屏幕应该朝哪个方向。MPU6050 的加速度计输出的是重力在各轴上的分量当设备立起来、屏幕面对用户时重力在屏幕平面内。可以用下面的公式计算一个角度float getScreenAngle(float ax, float ay) { return atan2(ax, ay) * 180.0 / PI; }这个角度表示屏幕长边相对重力方向的角度。实际安装时MPU6050 的方向与屏幕方向未必一致所以第一次运行要先通过串口打印这个角度然后根据实际显示方向调整阈值。int desiredRotation -1; void updateAutoRotation(float ax, float ay) { float angle getScreenAngle(ax, ay); if (angle 45 angle 135) { desiredRotation 0; // 竖屏 } else if (angle -45 angle 45) { desiredRotation 1; // 横屏 } else if (angle -135 angle -45) { desiredRotation 2; // 竖屏倒置 } else { desiredRotation 3; // 横屏倒置 } }这里要加一个迟滞机制否则角度落在阈值附近时屏幕会在两个方向之间反复切换。简单做法是判断desiredRotation与当前rotation不同并且持续 500ms 以上才真正执行旋转。转屏时最重要的操作顺序是if (desiredRotation ! currentRotation) { currentRotation desiredRotation; tft.setRotation(currentRotation); drawCurrentView(); // 必须重新布局 }如果不重绘屏幕只会旋转坐标方向但旧内容仍然停留在显存逻辑中显示效果会错乱。6. 番茄钟与时钟界面实现6.1 番茄钟状态机番茄钟的核心是一个有限状态机。状态包括待机、工作、休息、暂停。状态含义进入条件退出条件IDLE空闲初始化开始命令WORK工作倒计时用户开始或暂停后恢复倒计时结束 / 暂停BREAK休息倒计时WORK 结束倒计时结束 / 跳过PAUSED暂停WORK 或 BREAK 中触发暂停恢复或重新开始enum PomodoroState { POMODORO_IDLE, POMODORO_WORK, POMODORO_BREAK, POMODORO_PAUSED }; PomodoroState pomodoroState POMODORO_IDLE; unsigned long workDurationSec 25 * 60; unsigned long breakDurationSec 5 * 60; unsigned long remainingSec 0; unsigned long lastTick 0;状态更新逻辑放在loop()中使用millis()判断时间差避免delay()阻塞整个程序。每过 1 秒剩余时间减 1void updatePomodoro() { unsigned long now millis(); if (now - lastTick 1000) return; lastTick now; if (pomodoroState POMODORO_WORK) { if (remainingSec 0) { remainingSec--; } else { pomodoroState POMODORO_BREAK; remainingSec breakDurationSec; } } else if (pomodoroState POMODORO_BREAK) { if (remainingSec 0) { remainingSec--; } else { pomodoroState POMODORO_IDLE; remainingSec 0; } } }6.2 时钟和番茄钟画面绘制界面划分区域屏幕上半部分显示当前时间中间显示日期下半部分显示番茄钟状态和倒计时。void drawCurrentView() { tft.fillScreen(TFT_BLACK); int w tft.width(); int h tft.height(); // 时间区域 tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.setTextFont(7); tft.drawString(timeStr, w / 2, h / 3); // 日期区域 tft.setTextFont(2); tft.setTextColor(TFT_YELLOW, TFT_BLACK); tft.drawString(dateStr, w / 2, h / 3 40); // 番茄钟区域 tft.setTextFont(4); tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.drawString(pomodoroStr, w / 2, h * 2 / 3); }每次旋转后调用drawCurrentView()它会根据tft.width()和tft.height()调整布局这是整篇文章中提到最多的原则因为在同时支持手势和自动转屏的项目里布局函数必须是无状态的依赖当前位置和角度决定显示位置。6.3 NTP 时间同步时间来源使用 NTP需要 WiFi 连接。在setup()中先连接 WiFi再调用#include time.h const char* ssid your_ssid; const char* password your_password; void setupTime() { configTime(8 * 3600, 0, ntp.aliyun.com, cn.pool.ntp.org); struct tm timeinfo; int retry 0; while (!getLocalTime(timeinfo) retry 20) { delay(500); retry; } }configTime的第一个参数是秒级时区偏移这里按东八区 8 小时处理。等getLocalTime返回 true 后才能保证localtime获取的时间有效。**这里有一个常见坑**如果在loop()中频繁调用getLocalTime而 WiFi 断开getLocalTime可能会一直返回 false甚至导致程序卡顿。建议只在loop()中使用一次getLocalTime更新全局时间字符串并且把 WiFi 断线重连的逻辑独立出来。7. 主循环调度、烧录与验证7.1 主循环结构设计主循环采用非阻塞调度每一轮只做四件事void loop() { unsigned long now millis(); // 1. 更新传感器数据 mpu.update(); float ax mpu.getAccX(); float ay mpu.getAccY(); // 2. 检测手势事件 if (detectSwipe(ax, now)) { switchPage(); } // 3. 更新自动转屏 updateAutoRotation(ax, ay); if (desiredRotation ! currentRotation) { if (now - rotationStartTime 500) { currentRotation desiredRotation; tft.setRotation(currentRotation); drawCurrentView(); } } else { rotationStartTime now; } // 4. 更新番茄钟和时钟显示 updatePomodoro(); updateClockDisplay(now); }这个结构的关键是避免阻塞。mpu.update()内部会读取 I2C 数据耗时很短。detectSwipe和updateAutoRotation都不做耗时操作。整个loop()的周期可以稳定在几毫秒以内这样手势检测不会漏掉。7.2 编译烧录与首次运行编译前在 Arduino IDE 中确认以下设置参数推荐值说明BoardESP32 Dev Module按实际开发板选择Flash Size4MB 或按板子实际选择选错可能导致启动异常Partition SchemeDefault 4MB with spiffs不需要大分区时使用默认即可Upload Speed115200 或 921600921600 更快不稳再降首次烧录成功后打开串口监视器波特率设置为 115200。正常启动时会看到类似下面的输出Connecting to WiFi... WiFi connected, IP address: 192.168.1.100 MPU6050 initialized NTP time fetched如果MPU6050 initialized没有出现先检查 I2C 地址和接线。7.3 功能验证清单功能操作方式预期结果时钟显示等待启动完成屏幕显示正确本地时间番茄钟开始挥动一次或调用串口命令进入 WORK 倒计时番茄钟暂停再次特定手势WORK 状态暂停时间保留自动转屏把设备横放屏幕方向跟随变化内容重新布局手势切页左右挥动设备页面在时钟和番茄钟之间切换这里需要注意串口命令和手势事件会共用同一个状态修改接口验证时可以用串口输入快速测试状态机再确认手势逻辑是否产生了预期事件。这样可以分离“状态机 bug”和“手势识别 bug”。8. 常见问题与排查路径8.1 白屏、花屏、卡死现象常见原因检查方式处理建议白屏User_Setup.h 未改或芯片型号不对检查 ILI9341_DRIVER 是否启用重新核对配置白屏CS/DC/RST 接错对照引脚表用万用表量通断重新接线花屏SPI 频率过高降低 SPI_FREQUENCY降到 10000000 以下花屏电源电压不足测量 3.3V/5V 电压改善供电烧录后反复重启Flash 配置不对查看串口 boot 信息选择正确 Flash Size排查顺序应该是先看串口输出确认程序在跑再看电源电压最后检查屏幕接线和配置。8.2 MPU6050 读数异常传感器初始化成功但数据不变化或者数值巨大通常不是代码问题而是接线和供电噪声。MPU6050 的 SDA 和 SCL 线上是否需要外部上拉电阻取决于模块设计。绝大多数现成模块板上已经带上拉不需要额外加。如果mpu.getAccX()一直为 0先检查 I2C 地址。在初始化代码中加入Wire.begin(21, 22); byte error 0; Wire.beginTransmission(0x68); error Wire.endTransmission(); if (error ! 0) { Serial.println(MPU6050 not found at 0x68); }如果返回错误把地址改成 0x69 再试。AD0 接 GND 是 0x68接 3.3V 是 0x69这是最常见的问题。8.3 自动转屏不触发或抖动自动转屏不触发先看串口打印的angle值是否随设备旋转而变化。如果角度始终不变说明坐标轴理解有误需要调整atan2(ax, ay)的参数顺序。如果角度在阈值附近抖动就是缺少迟滞逻辑。改善抖动的方法有两个第一连续多次采样取平均第二增加状态切换的持续条件比如连续 500ms 都指向同一个方向才切换。8.4 番茄钟时间丢失或延迟使用millis()做计时时如果代码中存在delay()时间计算就会被拖慢。NTP 同步的configTime和 WiFi 连接过程都可能阻塞数秒期间番茄钟应该暂停或顺延。更稳妥的做法是把“总流程是否阻塞”作为设计约束任何网络操作都放到启动阶段主循环中不做网络请求。8.5 手势误触发手势误触发通常有三个来源原因现象解决阈值过低放在桌面上也会切页提高 GESTURE_THRESHOLD机械振动敲击桌面触发增加事件冷却时间时间窗过长一次挥动触发两次缩短 GESTURE_TIMEOUT调试时打开串口实时打印ax值观察挥动时幅值范围再确定阈值。不要拍脑袋写一个 0.6g 就结束不同安装方式、不同外壳重量阈值差异很大。9. 最佳实践与扩展方向9.1 从学习原型到稳定桌面设备学习阶段用面包板和杜邦线没有问题。如果要把这个 HUD 时钟放在桌面上长期运行需要做几件事把屏幕和 ESP32 用排针或接线端子固定避免接触不良。使用带屏蔽或较短的杜邦线SPI 频率适当降低。给设备做一个外壳固定 MPU6050 的方向避免传感器松动导致手势判定不稳定。增加掉电恢复番茄钟剩余时间写入 NVS 或 SPIFFS重启后可以恢复。日志输出分级开发时打印详细信息正式运行时只打印关键错误。9.2 可以继续扩展的功能这里只列性价比高的扩展方向增加 BLE 手机 App 控制用手机启动/暂停番茄钟修改番茄钟时长。增加 DHT11/DHT22 温湿度传感器在时钟界面显示环境数据。增加 Web 配置页面用 ESP32 启动一个轻量 HTTP 服务配置 WiFi、番茄钟时长等参数不需要重新烧录。增加触摸功能2.8 寸模块如果带 XPT2046 触摸芯片可以直接配合 TFT_eSPI 的触摸支持。在 SPIFFS 中存放字体、开机图或背景图让界面更丰富。9.3 给新手的练习建议如果刚接触传感器和屏幕不建议直接抄整篇代码。可以按下面的顺序练习先用 TFT_eSPI 示例点亮屏幕确认显示正常。单独读取 MPU6050用串口打印ax、ay、angle观察设备旋转时数值变化。手动设置setRotation(0~3)理解坐标变化。做一个没有手势的番茄钟先用串口或按键切换状态。最后把手势和自动转屏接入完成整个 HUD 循环。这个顺序的好处是每一层都建立在前一层已验证的基础上出现问题时能快速定位是显示层、传感器层还是状态机层的问题。做这类项目时最值得记住的判断是不要把界面和逻辑耦合在一起。屏幕旋转、手势事件、番茄钟状态三者之间通过事件和状态解耦界面只负责根据状态绘制。只要这个分层清晰后续加蓝牙、加触摸、加温湿度都不会破坏核心结构。