需求洞察
你有没有过这种经历:说好 9 点开始写代码,结果一抬头已经 11 点半,中间连一口水都没喝?或者打开了番茄 ToDo App 切到 25 分钟倒计时,然后顺手切到浏览器刷了 10 分钟微博,等反应过来已经过了 40 分钟?
App 类的番茄钟有个致命弱点——它跑在你想逃避的那个屏幕里。你想专注,但番茄钟在的窗口本身就是干扰源。于是 Forest、番茄 ToDo 这类 App 最大的痛点不是功能不够,而是用户根本没有切回 App 去看倒计时的动力。
本项目的目标就是把番茄钟从屏幕里请出来,用一块 ESP32 + 一个会举牌的小机器人,让 25 分钟的专注和久坐提醒变成物理世界的事件——你能看见舵机旋转、OLED 倒计时、听到蜂鸣器嘀嗒,而不是依赖一个随时会被切走的 App。
核心痛点
- 番茄钟 App 切走就忘:浏览器 / 微信一开,番茄钟就成摆设,倒计时变成"心理安慰"
- 久坐 1 小时无感知:专注状态下身体僵硬、颈椎僵硬,下班才发现腰酸背痛
- 手机通知被批量忽略:每天几十条通知,久坐提醒混在其中早就被拇指滑掉
- 想用物理反馈替代数字反馈:视觉(舵机举牌)+ 听觉(蜂鸣器)+ 状态屏(OLED)三通道提醒,比单通道通知高 3 倍响应率
方案设计
本项目采用「感知 + 主控 + 显示 + 执行」四件套架构,以 ESP32 为大脑,MPU6050 感知桌面微动,OLED 显示倒计时,SG90 舵机举小牌,蜂鸣器提供听觉反馈——所有逻辑都跑在本地,无需联网、无需 App:
| 模块 | 型号 | 功能 |
|---|---|---|
| 主控 | ESP32 DevKit V1 | 240MHz 双核,34 GPIO,I2C/SPI/PWM 全支持 |
| 姿态感知 | MPU6050 六轴陀螺仪 | I2C 接口,3 轴加速度 + 3 轴角速度,检测桌面微动 |
| 状态显示 | SSD1306 OLED 0.96" | 128x64 I2C 接口,大数字倒计时 + 当前状态 |
| 物理反馈 | SG90 舵机 + 5cm 硬纸板小牌 | PWM 控制 0-180°,举起「起来走走」等 3 面提示 |
| 听觉反馈 | 有源蜂鸣器 + NPN S8050 | GPIO 12 控制,每秒嘀嗒 / 结束时长鸣 |
番茄工作法状态机
经典的番茄工作法是 25 分钟专注 + 5 分钟休息,每 4 轮进入一次 15 分钟长休息。本项目把它写成 ESP32 状态机:
| 状态 | 持续时间 | OLED 显示 | 舵机 | 蜂鸣器 |
|---|---|---|---|---|
| FOCUS 专注 | 25 分钟(1500 秒) | 大数字倒计时 + 番茄图标 | 0° 隐藏 | 最后 5 秒每声嘀嗒 |
| BREAK 短休息 | 5 分钟(300 秒) | 小字倒计时 + 提示「看远方」 | 90° 半举牌 | 静默 |
| LONG_BREAK 长休息 | 15 分钟(900 秒) | 大字「LONG BREAK」+ 倒计时 | 180° 全举牌 | 静默 |
久坐检测原理
MPU6050 以 100Hz 频率读取桌面三轴加速度。当用户坐在桌前专注于代码时,桌面的微动很小(呼吸、键盘敲击);但起身去倒水、伸懒腰、走动时,加速度会有 0.2g 以上的明显变化。我们设定阈值 |accel| < 0.05g 持续 50 分钟(3000 秒),即触发「久坐」提醒:
- 蜂鸣器短促三声「哔-哔-哔」
- 舵机旋转到 180° 全举牌(小牌写「起来走走」)
- OLED 顶部红字「SIT TOO LONG」
- 检测到加速度突变(用户起身)即自动收回小牌
关键设计:久坐提醒不打断当前番茄状态。哪怕在 FOCUS 第 24 分钟久坐了 50 分钟,舵机举牌 30 秒后收回,番茄继续倒计时。这种"提醒但不打断"的设计是产品体验的灵魂。
技术亮点
- I2C 总线复用:MPU6050 + OLED 共用 GPIO 21/22 两条线(地址 0x68 + 0x3C 不冲突)
- PWM 舵机独立供电:SG90 峰值电流 700mA,必须外部 5V/2A 供电,不能用 ESP32 3.3V
- 状态机驱动:所有逻辑封装在
updateState()函数,加新模式(如「会议模式」)只需 5 行代码 - 零联网:完全本地运行,断网不影响,配网步骤为零
- 二次开发:番茄时长 / 久坐阈值 / 举牌文字 都在代码顶部宏定义,改一行就能定制
物料清单
总成本 ¥84,比 Forest(¥18/年会员)+ 番茄 ToDo 订阅加起来便宜,且 100% 离线。所有物料在淘宝 / 嘉立创都能买到,初学者建议买「ESP32 入门套件 + SG90 套装」更划算。
| 数量 | 物料 | 单价 | 小计 |
|---|---|---|---|
| 1 | ESP32 DevKit V1(Type-C 口,30 引脚版) | ¥30 | ¥30 |
| 1 | MPU6050 GY-521 模块(三轴加速度 + 三轴陀螺仪) | ¥5 | ¥5 |
| 1 | SG90 9g 舵机(180°,扭矩 1.8kg·cm) | ¥10 | ¥10 |
| 1 | SSD1306 OLED 0.96 寸显示屏(I2C 4 针版) | ¥12 | ¥12 |
| 1 | 有源蜂鸣器模块(3.3V 驱动) | ¥2 | ¥2 |
| 1 | NPN 三极管 S8050(蜂鸣器驱动,必备) | ¥2 | ¥2 |
| 1 包 | 杜邦线(公-公 40 根 + 公-母 40 根) | ¥5 | ¥5 |
| 1 | 面包板 400 点(SYB-170) | ¥5 | ¥5 |
| 1 | 5V 2A 电源适配器(DC 5.5x2.1 母头) | ¥10 | ¥10 |
| 1 张 | 硬卡纸 5cm×5cm(3 面手写小牌) | ¥3 | ¥3 |
| 总计 | ¥84 | ||
采购建议:
- ESP32 买Type-C 口 V1 改进版(丝印清楚、Type-C 不松动),不要买 Micro-USB 老版
- MPU6050 选 GY-521 模块(带稳压 + 上拉电阻),不要买裸芯片(要自己焊)
- SG90 买配 3 种舵盘的(X 型 / 十字 / 直线),舵盘粘纸牌用 X 型最稳
- OLED 选 0.96 寸 4 针脚版本(VCC/GND/SCL/SDA),不要买 7 针 SPI 版本(接线复杂一倍)
- 蜂鸣器买有源(通电就响),不要买无源(需要 PWM 驱动,代码更复杂)
接线与结构
按 ESP32 DevKit V1 的丝印实际位置说明(不是抽象 GPIO 编号):
ESP32 排针位置参考
- 左侧排针(从下到上):VIN / GND / D13 / D12 / D14 / D27 / D26 / D25 / D33 / D32 / D35 / D34 / VN / IO / VP / EN
- 右侧排针(从下到上):3V3 / GND / D15 / D2 / D4 / RX2 / TX2 / D5 / D18 / D19 / D21 / RX0 / TX0 / D22 / D23
接线清单(带颜色 + 丝印)
| 模块 | 引脚 | 颜色 | 连接目标 | 丝印 |
|---|---|---|---|---|
| MPU6050 | VCC | 红线 | ESP32 3.3V | 右侧排针第 1 脚,标 3V3 |
| MPU6050 | GND | 黑线 | ESP32 GND | 右侧排针第 2 脚,标 GND |
| MPU6050 | SDA | 黄线 | ESP32 GPIO 21 | 右侧排针第 11 脚,标 D21 |
| MPU6050 | SCL | 白线 | ESP32 GPIO 22 | 右侧排针第 14 脚,标 D22 |
| MPU6050 | AD0 | — | 接 GND | I2C 地址锁定为 0x68(AD0 接 VCC 则是 0x69) |
| OLED 0.96" | VCC | 红线 | ESP32 3.3V | 右侧排针第 1 脚,标 3V3 |
| OLED 0.96" | GND | 黑线 | ESP32 GND | 右侧排针第 2 脚 |
| OLED 0.96" | SDA | 黄线 | ESP32 GPIO 21 | 与 MPU6050 共用 I2C 总线 |
| OLED 0.96" | SCL | 白线 | ESP32 GPIO 22 | 与 MPU6050 共用 I2C 总线 |
| SG90 舵机 | 信号线(橙) | 橙线 | ESP32 GPIO 13 | 左侧排针第 3 脚,标 D13 |
| SG90 舵机 | VCC(红) | 红线 | 外部 5V 2A 电源正极 | DC 5.5x2.1 母头内芯 |
| SG90 舵机 | GND(棕) | 黑线 | ESP32 GND + 外部 5V 电源负极 | 务必共地!否则舵机会抖 |
| 蜂鸣器 | VCC(+) | 红线 | 外部 5V 电源正极 | 通过 NPN 集电极供电 |
| 蜂鸣器 | GND(-) | 黑线 | ESP32 GND | 右侧排针第 2 脚 |
| 蜂鸣器控制 | NPN 基极 | 绿线 | ESP32 GPIO 12 | 左侧排针第 4 脚,标 D12(经 1kΩ 电阻) |
⚠️ 关键提醒:
- SG90 舵机必须用外部 5V 电源,不要用 ESP32 的 VIN 或 3.3V(舵机峰值电流 700mA,会烧 ESP32 稳压器)
- 务必共地:舵机 GND 必须和 ESP32 GND 接在一起,否则 PWM 信号会浮空,舵机会乱抖(这是最常见的故障)
- 蜂鸣器必须用 NPN 驱动:ESP32 GPIO 输出电流仅 12mA,不足以驱动蜂鸣器。S8050 三极管基极串联 1kΩ 电阻到 GPIO 12,集电极接蜂鸣器,发射极接地
- MPU6050 的 AD0 引脚:默认悬空是 0x68,接 GND 锁定 0x68,接 3.3V 切到 0x69。本项目接 GND 保持稳定
- I2C 上拉电阻:MPU6050 和 OLED 模块通常自带 4.7kΩ 上拉,如果发现 I2C 不稳定,可外加一对 4.7kΩ 到 3.3V
物理结构:舵机小牌制作
用 5cm × 5cm 的硬卡纸(便签纸背面加厚的硬纸即可),裁成 3 张分别写上:
- 面 A:「🍵 起来走走」— 久坐超 50 分钟时举起
- 面 B:「👀 看远方」— BREAK 短休息时举起
- 面 C:「💧 喝杯水」— LONG_BREAK 长休息时举起
用双面胶把 3 张小牌粘成三角形(每张之间夹角 120°),底部贴在 SG90 舵机的 X 型舵盘上即可。当 ESP32 写入不同角度(0° / 90° / 180°)时,对应的小牌会自动转到正面朝前。
核心代码
使用 Arduino 框架,需要安装 4 个库:Wire(内置)、Adafruit_MPU6050、Adafruit_SSD1306、Servo(内置)。所有库都能在 Arduino IDE 的库管理器里搜到。
#include <Wire.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_SSD1306.h>
#include <Servo.h>
// ====== 宏定义(改这里就能定制行为) ======
#define FOCUS_SECONDS 25 * 60 // 专注时长 25 分钟
#define BREAK_SECONDS 5 * 60 // 短休息 5 分钟
#define LONG_BREAK_SEC 15 * 60 // 长休息 15 分钟
#define CYCLES_BEFORE_LONG 4 // 每 4 轮后进入长休息
#define SIT_THRESHOLD_G 0.05f // 久坐阈值(g,加速度变化 < 此值判定为静坐)
#define SIT_MINUTES 50 // 久坐多少分钟触发提醒
#define SIT_REMIND_SEC 30 // 举牌持续秒数
#define SERVO_PIN 13 // 舵机信号 GPIO 13(左侧排针第 3 脚 D13)
#define BUZZER_PIN 12 // 蜂鸣器控制 GPIO 12(左侧排针第 4 脚 D12)
#define OLED_ADDR 0x3C // OLED I2C 地址
#define SCREEN_W 128
#define SCREEN_H 64
// ====== 全局对象 ======
Adafruit_MPU6050 mpu;
Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, -1);
Servo myServo;
// ====== 状态机 ======
enum State { STATE_FOCUS, STATE_BREAK, STATE_LONG_BREAK };
State currentState = STATE_FOCUS;
unsigned long stateStartMs = 0; // 当前状态起始时间(毫秒)
int focusCount = 0; // 已完成的专注轮数
// 久坐检测
unsigned long sitStartMs = 0; // 开始静坐的时间
bool sitAlarmActive = false; // 久坐报警激活中
unsigned long sitAlarmEndMs = 0; // 报警结束时间
// 倒计时(每秒更新)
unsigned long lastTickMs = 0;
void setup() {
Serial.begin(115200);
delay(200);
// I2C 初始化(默认 SDA=21, SCL=22,与 MPU6050 + OLED 共用)
Wire.begin(21, 22);
// MPU6050 初始化
if (!mpu.begin()) {
Serial.println(F("MPU6050 not found, check wiring!"));
while (1) delay(1000); // 卡住等待排查
}
Serial.println(F("MPU6050 OK"));
mpu.setAccelerometerRange(MPU6050_RANGE_2_G); // ±2g 适合桌面微动
mpu.setFilterBandwidth(MPU6050_BAND_5_HZ); // 低通滤波,去掉高频噪声
// OLED 初始化
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println(F("OLED not found, check wiring!"));
while (1) delay(1000);
}
Serial.println(F("OLED OK"));
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
// 舵机初始化
myServo.attach(SERVO_PIN);
myServo.write(0); // 初始收起小牌
// 蜂鸣器初始化
pinMode(BUZZER_PIN, OUTPUT);
digitalWrite(BUZZER_PIN, LOW);
// 开机自检:舵机转到 90° 再回 0°,蜂鸣器短响一声
myServo.write(90);
delay(300);
myServo.write(0);
tone(BUZZER_PIN, 1000, 100);
delay(300);
// 状态机起始时间
stateStartMs = millis();
Serial.println(F("=== Pomodoro Robot Started ==="));
}
void loop() {
unsigned long now = millis();
// 每秒触发一次状态机更新 + 倒计时刷新
if (now - lastTickMs >= 1000) {
lastTickMs = now;
updateStateMachine();
updateOLED();
}
// 实时检测久坐(每 100ms 一次即可,MPU6050 本身 100Hz)
static unsigned long lastSitCheck = 0;
if (now - lastSitCheck >= 100) {
lastSitCheck = now;
checkSittingDuration();
}
// 处理举牌持续时间
if (sitAlarmActive && now > sitAlarmEndMs) {
sitAlarmActive = false;
myServo.write(0); // 收回小牌
}
delay(10);
}
// ====== 状态机更新 ======
void updateStateMachine() {
unsigned long elapsed = (millis() - stateStartMs) / 1000;
if (currentState == STATE_FOCUS && elapsed >= FOCUS_SECONDS) {
// 专注结束 → 进入休息
currentState = (focusCount + 1) % CYCLES_BEFORE_LONG == 0
? STATE_LONG_BREAK
: STATE_BREAK;
stateStartMs = millis();
focusCount++;
if (currentState == STATE_LONG_BREAK) {
myServo.write(180); // 长休息 → 全举牌「喝杯水」
tone(BUZZER_PIN, 1500, 500);
} else {
myServo.write(90); // 短休息 → 半举牌「看远方」
tone(BUZZER_PIN, 1200, 300);
}
Serial.print(F("Pomodoro cycle ")); Serial.println(focusCount);
}
else if (currentState == STATE_BREAK && elapsed >= BREAK_SECONDS) {
// 短休息结束 → 回到专注
currentState = STATE_FOCUS;
stateStartMs = millis();
myServo.write(0);
Serial.println(F("Back to FOCUS"));
}
else if (currentState == STATE_LONG_BREAK && elapsed >= LONG_BREAK_SEC) {
// 长休息结束 → 回到专注
currentState = STATE_FOCUS;
stateStartMs = millis();
myServo.write(0);
Serial.println(F("Back to FOCUS (after long break)"));
}
// 专注最后 5 秒每秒嘀嗒
if (currentState == STATE_FOCUS) {
unsigned long remaining = FOCUS_SECONDS - elapsed;
if (remaining > 0 && remaining <= 5) {
tone(BUZZER_PIN, 2000, 80);
}
}
}
// ====== 久坐检测 ======
void checkSittingDuration() {
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
// 计算三轴加速度的绝对值之和(单位:m/s²)
float ax = a.acceleration.x;
float ay = a.acceleration.y;
float az = a.acceleration.z;
float accelMag = abs(ax) + abs(ay) + abs(az);
// 转换为 g(1g ≈ 9.8 m/s²),减去静态重力 1g 后得到「动态加速度」
float dynamicG = (accelMag / 9.8f) - 1.0f;
if (dynamicG < 0) dynamicG = 0;
bool isStill = (abs(ax / 9.8f) < SIT_THRESHOLD_G) &&
(abs(ay / 9.8f) < SIT_THRESHOLD_G) &&
(abs(az / 9.8f - 1.0f) < SIT_THRESHOLD_G);
if (isStill) {
if (sitStartMs == 0) {
sitStartMs = millis(); // 开始计时
}
unsigned long sitMin = (millis() - sitStartMs) / 60000;
if (sitMin >= SIT_MINUTES && !sitAlarmActive) {
triggerSitAlarm();
}
} else {
sitStartMs = 0; // 有动静就重置
}
}
// ====== 触发久坐报警 ======
void triggerSitAlarm() {
sitAlarmActive = true;
sitAlarmEndMs = millis() + SIT_REMIND_SEC * 1000UL;
myServo.write(180); // 举牌
tone(BUZZER_PIN, 1500, 100);
delay(150);
tone(BUZZER_PIN, 1500, 100);
delay(150);
tone(BUZZER_PIN, 1500, 100);
Serial.println(F("!! SIT TOO LONG !!"));
}
// ====== OLED 显示 ======
void updateOLED() {
display.clearDisplay();
unsigned long elapsed = (millis() - stateStartMs) / 1000;
unsigned long total = (currentState == STATE_FOCUS) ? FOCUS_SECONDS
: (currentState == STATE_BREAK) ? BREAK_SECONDS
: LONG_BREAK_SEC;
unsigned long remaining = (total > elapsed) ? (total - elapsed) : 0;
int mm = remaining / 60;
int ss = remaining % 60;
// 第 1 行:状态标签
display.setTextSize(1);
display.setCursor(0, 0);
switch (currentState) {
case STATE_FOCUS: display.print(F(">> FOCUS #")); display.println(focusCount + 1); break;
case STATE_BREAK: display.println(F(">> BREAK 5min")); break;
case STATE_LONG_BREAK: display.println(F(">> LONG BREAK")); break;
}
// 第 2-3 行:大数字倒计时
display.setTextSize(3);
display.setCursor(10, 16);
if (mm < 10) display.print(F("0"));
display.print(mm);
display.print(F(":"));
if (ss < 10) display.print(F("0"));
display.println(ss);
// 第 4 行:进度条
display.setTextSize(1);
display.setCursor(0, 50);
int barW = map(elapsed, 0, total, 0, SCREEN_W - 12);
display.print(F("["));
for (int i = 0; i < SCREEN_W - 12; i++) {
display.print(i < barW ? F("=") : F(" "));
}
display.print(F("]"));
// 第 5 行:久坐提醒
if (sitAlarmActive) {
display.setCursor(0, 58);
display.print(F("! SIT TOO LONG !"));
}
display.display();
}
代码说明:
- 第 12 行
FOCUS_SECONDS 25*60:改成15*60就是 15 分钟短专注(适合会议前的快速冲刺) - 第 16 行
SIT_THRESHOLD_G 0.05f:阈值越小越灵敏(桌面微动也算),建议实测后调整 - 第 17 行
SIT_MINUTES 50:默认 50 分钟,符合 WHO 久坐建议 - 第 56 行
mpu.setFilterBandwidth(MPU6050_BAND_5_HZ):5Hz 低通滤波能去掉键盘敲击的尖峰干扰,避免误判 - 第 90 行
stateStartMs = millis():用毫秒时间戳做状态切换,比 delay() 更精准,不阻塞主循环 - 第 124 行
tone(BUZZER_PIN, 2000, 80):专注最后 5 秒每秒嘀嗒,给用户心理准备 - 第 158 行
abs(ax/9.8f) < SIT_THRESHOLD_G:把加速度从 m/s² 转回 g 单位,与阈值对比 - 第 178 行
triggerSitAlarm():三声短促嘀嗒 + 举牌 30 秒,30 秒后自动收回
调试步骤
按以下顺序逐步调试,每步验证通过再进下一步(避免一次接 8 根线发现全错)。
第 1 步:MPU6050 自检(3 分钟)
- 接线:仅接 MPU6050 的 4 根线(VCC/GND/SDA/SCL)
- 烧录 Arduino IDE 自带的
File → Examples → Adafruit MPU6050 → basic_readings示例 - 打开串口监视器(115200 波特率),看到
Accel X: ... Y: ... Z: ...持续滚动 → 接线正确 - Z 轴静止时应接近 9.8(重力),X/Y 轴接近 0;倾斜模块时数字应有明显变化
- 看不到数据 → 检查 I2C 地址(用 Scanner 扫,0x68 或 0x69)、SDA/SCL 是否接反
第 2 步:OLED 自检(3 分钟)
- 接线:OLED 4 根线全接(与 MPU6050 共用 I2C 总线)
- 烧录
File → Examples → Adafruit SSD1306 → ssd1306_128x64_i2c示例 - 看到 OLED 显示一个跳动的方块 → 接线正确
- 看不到 → 检查 I2C 地址(用 Scanner 扫,0x3C 或 0x3D)、VCC/GND 接反
第 3 步:舵机自检(3 分钟)
- 接线:SG90 三根线接好(信号→D13,VCC→外部 5V,GND→ESP32 GND + 5V 电源负极共地)
- 烧录下面这段最小测试代码
- 看到舵机在 0° → 90° → 180° 之间循环 → 接线正确
- 舵机不动或抖动 → 检查 GND 是否共地、信号线接错引脚、外部电源是否通电
#include <Servo.h>
Servo s;
void setup() { s.attach(13); }
void loop() {
s.write(0); delay(1000);
s.write(90); delay(1000);
s.write(180); delay(1000);
}
第 4 步:蜂鸣器自检(2 分钟)
- 接线:NPN S8050 基极经 1kΩ 电阻到 D12,集电极接蜂鸣器+,发射极接 GND
- 烧录:
tone(12, 1000, 500);听到「哔」一声 → 接线正确 - 听不到 → 检查三极管引脚(E/B/C 不要认错,集电极是接蜂鸣器那脚)
第 5 步:整合测试(10 分钟)
- 烧录上面的核心代码
- 打开串口监视器(115200 波特率)
- 应看到 OLED 大字倒计时(25:00 → 24:59 → ...),舵机 0° 收起
- 等到 24:55 时,蜂鸣器每秒嘀嗒
- 25:00 切换到 BREAK,舵机转到 90° 半举牌
- 5 分钟 BREAK 结束后,舵机转回 0°,重新开始 FOCUS
- 把 MPU6050 放桌上完全不动,等待 50 分钟(或者临时把
SIT_MINUTES改成 1 测久坐) - 应触发三声嘀嗒 + 180° 全举牌,OLED 底部红字「SIT TOO LONG」
- 拿起 MPU6050 晃动一下,舵机应在 30 秒后收回
常见故障
- MPU6050 一直返回 0:I2C 地址不对(试 0x69,AD0 是否接 VCC)、SDA/SCL 接反、模块虚焊
- OLED 黑屏:I2C 地址不对(用 Scanner 扫)、VCC/GND 接反、模块是 SPI 版本(4 针脚但其实是 SPI)
- 舵机不动 / 抖 / 嘎嘎响:GND 没共地、信号线接错引脚、外部电源电流不够(用 5V/2A 适配器,不要 USB 充电头)
- 蜂鸣器不响:三极管 E/B/C 引脚认错、基极没串 1kΩ 电阻、GPIO 编号用错
- 久坐检测不灵敏:调小
SIT_THRESHOLD_G(如 0.02)、调小mpu.setFilterBandwidth(MPU6050_BAND_21_HZ) - 倒计时跑得太快 / 慢:检查
millis()是否被 delay() 阻塞太久(loop 里 delay 应 < 50ms)
设计图
系统架构图(ESP32 中心 + 5 个外围)
┌──────────────────────┐
│ ESP32 DevKit V1 │
│ (主控 + 状态机) │
└──────────┬───────────┘
│
┌───────┬───────┼───────┬────────┐
│ │ │ │ │
GPIO13 I2C(21/22) GPIO12 5V+GND 蜂鸣器
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌─────────┐ ┌──────┐
│SG90 │ │MPU6050│ │NPN │ │ 外部5V │ │有源 │
│舵机 │ │+OLED │ │S8050 │ │ 2A 电源 │ │蜂鸣器│
│(执行) │ │(感知+│ │(驱动) │ │(供电) │ │(听觉)│
│ │ │ UI) │ │ │ │ │ │ │
└──────┘ └──────┘ └──────┘ └─────────┘ └──────┘
番茄状态机流程图
| 当前状态 | 触发条件 | 切换到 | 舵机动作 |
|---|---|---|---|
| 启动 | 上电 | FOCUS #1 | 0° 收起 |
| FOCUS | 倒计时 25:00 → 0:00 | BREAK 或 LONG_BREAK(每 4 轮) | → 90° 或 180° |
| BREAK | 倒计时 5:00 → 0:00 | FOCUS #n+1 | → 0° |
| LONG_BREAK | 倒计时 15:00 → 0:00 | FOCUS #n+1 | → 0° |
| 任何状态 | MPU6050 检测久坐 ≥ 50 分钟 | 不切换(叠加报警) | → 180°,30 秒后回原位 |
久坐检测逻辑
| 步骤 | 判定 | 动作 |
|---|---|---|
| 1. 每 100ms 读取加速度 | |ax| < 0.05g 且 |ay| < 0.05g 且 |az-1| < 0.05g | 是 → 静坐计时 +1;否 → 计时归零 |
| 2. 静坐持续 ≥ 50 分钟 | — | 触发蜂鸣 3 声 + 舵机 180° + OLED 报警 |
| 3. 报警持续 30 秒 | — | 舵机回 0° |
| 4. 用户起身动作(加速度突变) | |a| - 1g > 0.1g | 静坐计时立即归零 |
I2C 总线设备地址表
| 设备 | I2C 地址 | 丝印 / 引脚配置 |
|---|---|---|
| MPU6050 | 0x68 | AD0 接 GND(如 AD0 接 3.3V 则为 0x69) |
| SSD1306 OLED | 0x3C | 默认(部分模块是 0x3D) |
扩展玩法
- 接 WiFi 同步到 Notion:每完成一个番茄,POST 到 Notion 数据库记录今日专注时长
- 加语音模块(DFPlayer Mini ¥8 + 小喇叭 ¥3):举牌时同步播放「该休息了」真人语音
- 加 RGB 灯环(WS2812 12 颗 ¥15):专注时冷白渐变,休息时暖橙呼吸(参考 06-26 D 主题)
- 换大舵机 MG90S(¥15):扭矩 2.2kg·cm,能举更重的纸牌或小人偶
- 加红外传感器(HC-SR501 ¥3):检测人离开工位自动暂停番茄,避免误判
- 改成长休息多级:每 8 轮进入 30 分钟长休息 + 屏幕推荐「出门走 15 分钟」
金句
"番茄钟是时间的便签,而 ESP32 让它站起来。"
本项目总成本 ¥84,比 Forest(¥18/年订阅)+ 番茄 ToDo 加起来还便宜,但获得了物理世界的真实反馈——你能听见舵机旋转的嗡嗡声、看见小牌被举起、感受到蜂鸣器的嘀嗒,而不只是屏幕上跳动的数字。久坐不再是无感知,因为你的桌面有一个 24 小时在线的「番茄小助手」。二次开发空间极大:番茄时长、久坐阈值、举牌文字都在代码顶部宏定义,改一行就能定制。动手做——让你的桌面会"说话"。