ESP32桌面HUD时钟:手势切换与自动转屏的番茄钟设计
把屏幕倒扣在桌面,成了很多人对抗手机干扰的最后一个办法。但你真的这么做之后,又会立刻发现一个尴尬:番茄钟还剩多久,下一次切换在什么时候,屏幕上一概看不到。这段时间我做了一个 ESP32 2.8寸屏时钟 Deep Warp HUD 小项目,把桌面变成一个只显示关键信息的仪表盘,用手势切换番茄钟状态,用自动转屏匹配屏幕摆放方向。做完之后我最大的感受是:这类小硬件项目,难点从来不是会接线和会显示,而是把“信息显示”变成一种稳定的、不打断人的存在。
“Deep Warp HUD”这个名字听起来挺唬人,实际拆开看并不玄乎:HUD 的体验本质是“余光可达”——你不用凑近屏幕,不用解锁,不用点开某个 App,只要眼角扫一下就知道现在处于什么时间段、番茄钟还剩多少。ESP32 在这个项目里也不是主角,真正的主角是交互方式:怎样让手势足够轻、转屏足够自然、状态切换足够稳定。
这篇文章会把项目拆成五层:硬件接线、最小显示系统、手势与自动转屏、源码结构、长期使用需要补的工程化能力。每层都有实测经验,也有我会建议的排查路径。
1. 先想清楚:桌面 HUD 时钟到底解决了什么问题
1.1 不是“做个时钟”,而是把信息放到余光里
很多人在做桌面小屏项目时,第一反应是“做个时钟显示时间”。这个方向不能说错,但容易做成三天新鲜。真正让这类设备有长期存在价值的,是它替代了某种高频、低注意力的“查看动作”。
以番茄钟为例,过去我的流程是:打开手机计时器,把手机放在桌面,屏幕朝上。结果每隔几分钟就会顺手点开通知、翻一屏新闻。后来改成手机倒扣,确实不看了,但计时进度也看不见了。番茄钟结束还需要靠铃声提醒,声音大,次数多了会烦。
而桌面 HUD 的形态解决的是这个问题:时间、当前阶段、剩余进度,都固定显示在一个不能刷、不能玩、不能弹通知的独立屏幕上。它不主动打扰你,但只要你愿意抬一下眼,信息就在那里。
从这个角度看,“Deep Warp HUD”更像一个信息面板设计思路:把散落在手机、电脑、墙上的信息,挑出两三个最关键的字段,放在一个固定的、低存在感的屏幕上。显示时间只是最小功能,番茄钟状态、专注轮次、甚至温度湿度,都可以放进来。
1.2 为什么选 ESP32,而不是树莓派或旧手机
有人会问:随便用一个旧手机不是更好?屏幕素质高,还带触摸。但从“做一台不打断人的 HUD”这个角度,旧手机反而有两个大问题。第一,它的系统推流能力太强,你会忍不住切出去;第二,它本质上还是一个需要充电、需要系统更新的移动设备,不能随开随用。
树莓派的问题是贵、重、开机慢。一个番茄钟设备不应该需要等 30 秒启动,也不应该占用一整块开发板的算力。
ESP32 的优势在几个点上:芯片便宜,开发板几十块钱;自带 WiFi 和蓝牙,后面要 NTP 校时、OTA 升级、甚至手机蓝牙控制都方便;Arduino 生态非常成熟,TFT_eSPI、LVGL、MPU6050 这类库都有大量现成示例。缺点是屏幕刷新、字库、动画都得自己写,和直接用手机相比,确实更“偏底层”。但对一个想理解显示和交互怎么配合的人来说,这恰恰是最有价值的部分。
如果你只是想“用”一个番茄钟,直接手机上装个 App 就够了。如果你想知道一台所谓 HUD 为什么能持续工作、怎么处理掉电、怎么自动适应转屏,ESP32 是很合适的学习对象。
2. 从点亮屏幕到显示时间:最小系统先跑通
2.1 硬件清单和驱动选择
我用的是一块常见的 ESP32 开发板,搭配 2.8 寸 SPI 屏幕。2.8 这个尺寸在桌面 HUD 场景里比较舒服:不大不小,周末放在键盘旁边刚好不挡视线。
屏幕的驱动芯片需要先确认。2.8 寸的 SPI 屏通常以 ILI9341 为主,但也有 ST7789、ST7735 模组。这直接决定后面的驱动配置,所以在接线之前,一定要看屏幕背面丝印或卖家资料,确认驱动 IC 型号。不知道驱动就去盲目初始化,很可能出现白屏或者颜色错位。
整个系统除了 ESP32 和屏幕外,我还加了一个加速度计模块(MPU6050)。在常见方案里,MPU6050 一个传感器就能同时干两件事:检测手势敲击/甩动,以及检测重力方向来做自动转屏。如果你只想做手势,也可以选择手势传感器 APDS9960,但它没有重力方向信息,自动转屏还得再外接传感器,布线复杂度会高不少。
接线建议参考下面这张表(以 ESP32 DevKit 类开发板、ILI9341 屏、MPU6050 为例):
| 模块 | 信号 | 常见引脚 | 说明 |
|---|---|---|---|
| 屏幕 | VCC | 3.3V | 逻辑供电,部分模组需要 5V,按丝印确认 |
| 屏幕 | GND | GND | 公共地 |
| 屏幕 | SCLK / SCK | GPIO18 | SPI 时钟 |
| 屏幕 | MOSI / SDI | GPIO23 | SPI 数据线 |
| 屏幕 | CS | GPIO5 | 片选 |
| 屏幕 | DC / RS | GPIO2 | 数据/命令选择 |
| 屏幕 | RST | GPIO4 | 复位,可接 GPIO 或 3.3V |
| 屏幕 | BL / LED | GPIO22 | 背光,可不接直接接 3.3V 长亮 |
| 加速度计 | SDA | GPIO21 | I2C 数据 |
| 加速度计 | SCL | GPIO22 | I2C 时钟 |
这个表不是绝对标准,不同开发板引脚布局、屏幕丝印名称都不一样。像 SCK 可能标成 SCL、MOSI 可能标成 SDI,要注意看模块资料。另外,背光和 I2C 的 SCL 如果都选 GPIO22 就会冲突,所以接线前要做一个简单的引脚复用检查。
2.2 TFT_eSPI 的引脚配置是第一个坑
SPI 屏幕的显示库,我建议直接用 TFT_eSPI。这个库对 ILI9341、ST7789 等常见驱动支持很好,也能处理旋转、字体、局部刷新。但它的配置方式非常“古早”:需要手动修改库里的 User_Setup.h 文件,而不是在代码里传参。
我经常看到有人第一次用这个库,屏幕死活不亮,最后发现是引脚没有改到 User_Setup.h 里。正确做法是先打开 User_Setup.h,启用你的驱动型号,注释掉其他不相干的驱动,然后按实际接线把 TFT_CS、TFT_DC、TFT_RST、TFT_MOSI、TFT_SCLK 改好。
示例配置结构(不要照抄,按你的接线来):
#define ILI9341_DRIVER #define TFT_WIDTH 240 #define TFT_HEIGHT 320 #define TFT_CS 5 #define TFT_DC 2 #define TFT_RST 4 #define TFT_MISO -1 // 这里只读屏幕通常不接 MISO #define TFT_MOSI 23 #define TFT_SCLK 18 #define SPI_FREQUENCY 27000000如果你用的是 ST7789 或 ST7735,就把第一行换成对应的型号。如果屏幕亮但不清晰,或者花屏,优先把 SPI_FREQUENCY 从 27MHz 降到 10MHz,再降到 6.5MHz 试试。很多花屏问题不是驱动不对,而是杜邦线太长或接线接触不良,高频传输出问题。
从这个配置里也可以看出这类项目的特点:它要求使用者必须清楚自己的硬件细节,而不是拿来就能用。你越早接受这个设定,后面的问题越好排查。
2.3 最小示例:让屏幕显示时间和一列状态字符
先不要急着做复杂界面。第一步是让屏幕能稳定显示一句话、一个数字、一个不断变化的时间。我一般会先写一个最小示例,验证屏幕基本通信:
#include <TFT_eSPI.h> TFT_eSPI tft; void setup() { tft.init(); tft.setRotation(0); tft.fillScreen(TFT_BLACK); } void loop() { tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextSize(4); tft.drawString(String(millis() / 1000), 30, 100); delay(100); }这段代码里只做一件事:每 100 毫秒刷新一次开机秒数。它能用来确认三件事:屏幕通信正常、文本绘制正常、黑色底色和绿色字体显示正常。如果这段都跑不出来,先别优化界面,回去查驱动和引脚。
要特别留意:fillScreen 之后不要让主循环卡在一个长期阻塞操作里。ESP32 虽然跑的是 FreeRTOS,但 Arduino 层在长时间不调用 delay 或 yield 的情况下,也可能触发看门狗复位。实际代码里时间计算和刷新也要尽量用非阻塞方式,不要用 delay 死等。
3. 手势控制和自动转屏:把交互成本降到最低
3.1 传感器选型:加速度计更适合这个场景
我在这个项目里选择加速度计而不是红外手势传感器,核心原因是“一个传感器解决两个需求”。用手势传感器只能识别滑动方向,做不了自动转屏;加速度计既能检测敲击/甩动,又能通过重力方向判断屏幕是竖放、横放还是倒置。
MPU6050 是很常见的六轴传感器,I2C 接口,Arduino 下有很多现成库。但它本身是 3.3V 供电,和 ESP32 连接比较方便。启动之后需要等待传感器稳定,直接读数据很容易读到异常值。
如果你用的是其他加速度计,比如 MPU9250、LIS3DH,思路是一样的:把加速度向量读出来,一个是做手势触发,一个是算角度。关键是先把原始数据画出来或者打印出来,观察一下噪声水平,再设定阈值。
3.2 手势识别的核心思路:短时冲击,而不是复杂姿态
所谓手势,在这个项目里我用的是一种很粗糙但非常实用的方式:检测“短时冲击”。具体来说,就是当加速度模长突然超过某个阈值,并且之后一小段时间内回到平稳,就认为用户敲了一下或甩了一下。
为什么不用复杂姿态识别?因为桌面番茄钟需要的操作只有一个:切换状态。更复杂的识别会增加误触风险,也不会让体验更好。你只需要让“用户轻轻碰一下设备”和“切换到下一个番茄钟阶段”之间形成可靠的因果关系。
一个常见的伪代码结构是这样的:
void handleGesture() { if (mpu.update()) { float mag = sqrt(ax * ax + ay * ay + az * az); if (mag > kGestureThreshold && millis() - lastGestureTime > kCooldownMs) { // 触发番茄钟阶段切换 tomatotState.next(); lastGestureTime = millis(); } } }这里有两个必须调好的参数。第一个是阈值 kGestureThreshold,太大会导致手势不灵敏,太小会把日常桌面震动、触摸屏抖动当成手势。第二个是冷却时间 kCooldownMs,一般为 1 到 2 秒,避免一次敲击触发多次切换。
更严格一点的做法,可以加一个稳定窗口:检测到冲击后,接下来 200 到 300 毫秒,加速度要回到接近 1g 的水平,才认为是一次有效手势。这个“冲击+稳定”的组合,能明显减少误触。
3.3 自动转屏:显示坐标跟着重力方向走
自动转屏的原理不复杂。加速度计静止时,测到的合加速度就是重力方向。我们可以通过 atan2 计算出屏幕当前相对重力的角度,然后根据角度把 TFT_eSPI 的 rotation 改成 0、1、2、3 中的某一个。
伪代码思路:
void handleAutoRotation() { float roll = atan2(ax, az) * 180.0 / PI; float pitch = atan2(ay, az) * 180.0 / PI; int newRotation = currentRotation; if (roll < -45) newRotation = 1; else if (roll > 45) newRotation = 3; else if (pitch < -45) newRotation = 2; else newRotation = 0; if (newRotation != currentRotation) { tft.setRotation(newRotation); currentRotation = newRotation; renderDashboard(); // 必须重新绘制整个界面 } }注意一个坑:setRotation 只是改变坐标映射,它不会自动重绘。旋转之后,旧的屏幕内容可能还保留,文字位置也不对。所以必须在 rotation 变化后,重新绘制整个界面。
另一个坑是旋转抖动。屏幕角度如果刚好在阈值附近,加速度计噪声会触发频繁切换。解决办法是增加死区:不要用 -45 和 45 作为唯一边界,可以改成 -60 和 60,并且切换完成后加一个 500 毫秒的确认窗口,短时间内不重复判断。
还有一点很重要:旋转之后 tft.width() 和 tft.height() 会互换。布局代码不能写死坐标,而要根据当前宽高动态计算。否则横屏转竖屏后,控件很容易出界或错位。
4. 源码结构解读:别把界面逻辑写在 loop 里
4.1 用一个状态机管理番茄钟,而不是一串 if
番茄钟表面的逻辑很简单:工作 25 分钟,休息 5 分钟,循环几轮。但如果你直接把这段逻辑塞进 loop,时间一长,代码会越来越乱。
我更建议用一个状态机来管理。状态本身可以非常简单:
| 状态 | 默认时长 | 触发条件 | 动作 |
|---|---|---|---|
| IDLE | 无 | 启动后进入 | 显示待机界面 |
| WORK | 25 分钟 | 手势触发/计时结束 | 进入专注计时 |
| SHORT_BREAK | 5 分钟 | 一次工作结束 | 进入短休息 |
| LONG_BREAK | 15 分钟 | 连续 4 轮工作结束 | 进入长休息 |
实际代码里,可以用一个 state 变量和两个时间戳变量来管理:stateStartTime 记录进入当前状态的毫秒时间,stateDuration 记录当前状态的总时长。每次判断“当前时间 - stateStartTime 是否大于 stateDuration”,如果大于,就切换到下一个状态。
这样做的好处是:界面层、手势层、时间计算层可以分开。手势只负责“请切换到下一阶段”,状态机负责“是否允许切换、切换后重置什么”,界面层只关心“当前状态是什么,剩余时间是多少”。
4.2 把显示刷新和事件处理分离
我在实测过程中最大的体会是:不要把所有事情都堆在 loop 的同一个函数里。一个常见反例是:读取传感器、请求 WiFi 校时、刷新屏幕、重连网络全部写在同一个大循环,最后屏幕疯狂闪烁,甚至复位。
推荐的结构是拆分事件和渲染:
void loop() { handleGesture(); // 事件:检测手势 handleAutoRotation(); // 事件:检测姿态 updateClock(); // 逻辑:更新时间和番茄钟 renderIfNeeded(); // 渲染:只在需要时重绘 delay(10); }renderIfNeeded 里可以做一个简单的脏标记。比如番茄钟剩余时间每秒变化,那就每秒重绘一次数字;但背景、边框、状态文字如果没变化,就不要反复 fillScreen。ESP32 虽然性能不算差,但全屏刷新次数过多会造成闪屏和背光闪烁。
对于一个小尺寸桌面 HUD 来说,局部刷新的收益非常明显。时间数字用字符串居中绘制,进度条用 fillRect 更新,都比每帧清空重画稳定得多。
4.3 源码中容易误解的几个位置
第一,TFT_eSPI 的 drawString 中 x 和 y 并不是固定的左上角坐标。它默认基于一个文本光标位置,你可以通过 setTextDatum 调整对齐方式。如果发现文字位置一直不对,先看看是不是文本基准点设置得不符合预期。
第二,millis() 会溢出。不要用“当前时间是否大于某个绝对时间”这种写法,要用差值:
uint32_t now = millis(); if (now - stateStartTime >= stateDuration) { // 切换状态 }第三,传感器读取可能会失败。MPU6050 刚上电或 I2C 线路不稳定时,返回的数据可能全为零或者异常。建议在读取前判断连接状态,在读取后做一次合理范围校验。否则手势和转屏都会受牵连。
5. 实测会遇到的问题和排查链路
5.1 现象:屏幕黑屏或白屏
这是最让人崩溃的问题。按照我的经验,排查顺序应该是:
- 先看背光是否亮。如果背光亮但无内容,说明屏幕供电和背光没问题,问题在显示数据通信。
- 确认驱动型号。ILI9341、ST7789、ST7735 的初始化命令不同,配置错了很难有正常输出。
- 检查 CS、DC、RST 三个控制引脚是否接对。MOSI 和 SCLK 接错会出现花屏或无显示,但 CS/DC 接错更容易直接黑屏。
- 降低 SPI 频率。长杜邦线在 27MHz 下很容易不稳定,降到 10MHz 再验证。
- 最后再怀疑模块本身。很多时候不是屏坏了,而是 MISO 没接导致的库初始化报错,或者接线接触不良。
5.2 现象:花屏、显示错位、颜色怪
花屏的问题,除了 SPI 频率过高,还要检查屏幕的颜色格式。有些屏幕默认 RGB 颜色顺序和库的默认值不同,你可能需要在 User_Setup.h 里开启 BGR 反转指令。搜索关键词“TFT_RGB_ORDER TFT_BGR”会出现大量讨论,说明这是很多人踩过的坑。
显示错位方面,最常出现在旋转之后。setRotation 改变后,width 和 height 交换,如果你还在用写死的 240、320 坐标,那横屏和竖屏之间一定会错位。建议所有布局都基于 tft.width() 和 tft.height() 动态计算。
还有就是屏幕刷新残留。如果从全屏填充改成局部刷新,旧文字没有清除干净,一般是因为你没有用背景颜色覆盖旧区域,或者 setTextColor(前景, 背景) 里的背景色没有设置成和填充色一致。
5.3 现象:手势不灵敏或自动转屏乱跳
手势不灵敏,先看传感器原始数据。串口打印出合加速度值,敲击设备时观察波形变化。如果阈值设到 1.3g 仍然频繁误触,说明噪声太大或者你手掌碰到桌面造成的震动被当成手势。这时可以把阈值提高到 1.5g,同时把稳定窗口加上。
自动转屏乱跳,通常是两件事没做好。第一是死区太窄,第二是切换后没有冷却时间。角度在不断抖动时,每次跨过阈值都会触发一次旋转,界面就会像抽风一样反复横竖切换。加一个至少 500 毫秒的确认窗口,能让体验稳定很多。
5.4 一个通用的排查顺序
| 现象 | 优先检查 | 如果还不行,再检查 |
|---|---|---|
| 黑屏 | 背光、驱动型号、CS/DC/RST 引脚 | SPI 频率、MISO、屏幕模块 |
| 花屏 | SPI 频率、杜邦线接触 | 颜色顺序 RGB/BGR、电源波动 |
| 文字错位 | setTextDatum、布局坐标 | 旋转后的 width/height 动态计算 |
| 手势失灵 | 阈值、冷却时间、稳定窗口 | I2C 地址、传感器噪声 |
| 转屏乱跳 | 死区、确认窗口 | 加速度计角度计算、接口连接 |
| 频繁复位 | 供电电流、背光峰值 | USB 线质量、独立供电 |
这套排查顺序的核心思想是“先外后内”:先看物理层,再看通信层,再看逻辑层。很多人一上来就怀疑代码,结果改了半天下拉电阻,最后发现只是杜邦线松了。
6. 从入门到长期使用,还差哪些工程化能力
6.1 时间从哪里来,掉电后怎么办
如果只用 millis() 计时,那只能算开机计时器,不叫时钟。做桌面 HUD,时间准确度直接决定它有没有长期使用价值。
最简单的方案是联网 NTP 校时。ESP32 连上 WiFi 后,请求 NTP 服务器拿 UTC 时间,再换算成北京时间。但联网带来的问题是要配网、要处理断网,如果网络不稳定,时钟就停在旧时间上。
不联网方案是用外部 RTC 模块,比如 DS3231。它的优点是离线保持时间准确,掉电后时间不丢,只是需要多一个模块、多一份初始化代码。
如果你想让设备掉电后还能恢复番茄钟状态,最简单的做法是定期把当前状态和时间戳保存到 ESP32 的 Preferences 或 SPIFFS / LittleFS 文件里。开机时读出来,判断上次记录的剩余时间是否还有效,再决定继续还是重置。对于“做到一半掉电”这种场景,这个能力很重要。
6.2 日志、配网和更新
调试阶段一定要开串口日志。尤其是传感器数据、状态切换、旋转角度变化,打印出来能省很多时间。但长期运行时,串口日志不该一直开着,否则会影响时序,也消耗 CPU。
配网方面,常见做法是把 WiFi SSID 和密码写死在代码里,最简单但不灵活。更进一步是开机进入配网模式:如果检测不到 WiFi,就启动一个热点或者让用户通过蓝牙配置。工程量会上升,但体验更好。
OTA 更新是这个项目后期很值得做的功能。设备放在桌上,每次改代码都要拔线重灌会越来越烦。但 OTA 也有风险:升级中断可能变砖。建议先做一个“按下某个按钮进入 OTA 模式”的入口,而不是一上来就做自动升级后台。
6.3 什么时候该换 LVGL?
如果你还停留在 TFT_eSPI 直接绘制,界面会慢慢变得不好扩展。一旦出现多个页面、滑动列表、复杂动画、触摸点击,直接用 TFT_eSPI 绘制会非常痛苦。这时就可以考虑 LVGL。
LVGL 的优点是控件系统成熟,写 UI 接近写移动端界面;缺点是需要学习布局和跑起来的组件流程,内存占用也明显更高。对番茄钟 + HUD 这类小项目来说,TFT_eSPI 完全够用。真正需要 LVGL 的场景是“我不需要关心每一个像素怎么画,我只想快速搭建一个界面”。
另外像 EEZ Studio 这类可视化工具可以配合 LVGL 生成界面,对不熟悉手写 UI 的人来说很友好。但它不等于插上就能用,还需要处理 ESP32 工程、LVGL 版本、屏幕驱动、触摸事件等一套东西。入门阶段,先把手写 TFT_eSPI 跑顺,再考虑升级 UI 框架,速度反而更快。
6.4 适用边界:这个方案适合谁,不适合谁
先说适合什么样的人。适合已经有一定 Arduino 或 ESP32 基础、想练习传感器和屏幕配合的人;适合认可“设备应该减少打扰”而不是“功能越多越好”的人;也适合愿意花周末时间调一个阈值、画一个简单进度条的人。
不适合的情况也很清晰。如果你需要的是一个带触摸、可滚动、可切换多个页面的桌面控制台,2.8 寸屏 + 手势加速度计显然不够。如果你想要 7 天不充电的电池设备,这个屏幕和 ESP32 组合并不算低功耗。如果你没有耐心看模块资料、只想“开箱即用”,这类自己接线的方案会让你很挫败。
从长期维护角度看,最容易出问题的不是代码,而是机械结构和供电。杜邦线插久了会松,屏幕背光长时间高亮度会老化,USB 供电不稳会导致 SPI 花屏。真正把它当长期桌面设备用,最终可能需要一块小 PCB、一个固定外壳、一个独立电源。这些都是后话,但提前知道会少走很多弯路。
回到我最初的问题:桌面 HUD 想要省下的,其实是你一次次拿起手机、解锁、看时间、再放下这个动作。这个过程看着只有几秒,但一天重复几十次,注意力就被切碎了几十次。一个能用手势切换状态、能自动适应转屏的小屏幕,解决的也不是“显示精度”,而是“交互摩擦”。
如果你也想做一个类似的 ESP32 2.8 寸屏时钟项目,我的建议是:先花半天时间把屏幕点亮,再把手势和转屏跑通,最后才考虑界面好不好看、要不要加 WiFi、要不要做 OTA。先让设备稳定可靠地工作一周,再谈工程化。因为这类项目的真正价值不在于跑通的那一刻,而在于它能不能在桌上安静地陪你度过接下来几十个专注循环。
