需求洞察

你有没有过这种经历:说好 9 点开始写代码,结果一抬头已经 11 点半,中间连一口水都没喝?或者打开了番茄 ToDo App 切到 25 分钟倒计时,然后顺手切到浏览器刷了 10 分钟微博,等反应过来已经过了 40 分钟?

App 类的番茄钟有个致命弱点——它跑在你想逃避的那个屏幕里。你想专注,但番茄钟在的窗口本身就是干扰源。于是 Forest、番茄 ToDo 这类 App 最大的痛点不是功能不够,而是用户根本没有切回 App 去看倒计时的动力。

本项目的目标就是把番茄钟从屏幕里请出来,用一块 ESP32 + 一个会举牌的小机器人,让 25 分钟的专注和久坐提醒变成物理世界的事件——你能看见舵机旋转、OLED 倒计时、听到蜂鸣器嘀嗒,而不是依赖一个随时会被切走的 App。

核心痛点

方案设计

本项目采用「感知 + 主控 + 显示 + 执行」四件套架构,以 ESP32 为大脑,MPU6050 感知桌面微动,OLED 显示倒计时,SG90 舵机举小牌,蜂鸣器提供听觉反馈——所有逻辑都跑在本地,无需联网、无需 App:

📐 系统架构
模块型号功能
主控ESP32 DevKit V1240MHz 双核,34 GPIO,I2C/SPI/PWM 全支持
姿态感知MPU6050 六轴陀螺仪I2C 接口,3 轴加速度 + 3 轴角速度,检测桌面微动
状态显示SSD1306 OLED 0.96"128x64 I2C 接口,大数字倒计时 + 当前状态
物理反馈SG90 舵机 + 5cm 硬纸板小牌PWM 控制 0-180°,举起「起来走走」等 3 面提示
听觉反馈有源蜂鸣器 + NPN S8050GPIO 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 秒),即触发「久坐」提醒:

关键设计:久坐提醒不打断当前番茄状态。哪怕在 FOCUS 第 24 分钟久坐了 50 分钟,舵机举牌 30 秒后收回,番茄继续倒计时。这种"提醒但不打断"的设计是产品体验的灵魂。

技术亮点

物料清单

总成本 ¥84,比 Forest(¥18/年会员)+ 番茄 ToDo 订阅加起来便宜,且 100% 离线。所有物料在淘宝 / 嘉立创都能买到,初学者建议买「ESP32 入门套件 + SG90 套装」更划算。

🛒 物料清单(2026 年 6 月淘宝均价)
数量物料单价小计
1ESP32 DevKit V1(Type-C 口,30 引脚版)¥30¥30
1MPU6050 GY-521 模块(三轴加速度 + 三轴陀螺仪)¥5¥5
1SG90 9g 舵机(180°,扭矩 1.8kg·cm)¥10¥10
1SSD1306 OLED 0.96 寸显示屏(I2C 4 针版)¥12¥12
1有源蜂鸣器模块(3.3V 驱动)¥2¥2
1NPN 三极管 S8050(蜂鸣器驱动,必备)¥2¥2
1 包杜邦线(公-公 40 根 + 公-母 40 根)¥5¥5
1面包板 400 点(SYB-170)¥5¥5
15V 2A 电源适配器(DC 5.5x2.1 母头)¥10¥10
1 张硬卡纸 5cm×5cm(3 面手写小牌)¥3¥3
总计¥84

采购建议

接线与结构

按 ESP32 DevKit V1 的丝印实际位置说明(不是抽象 GPIO 编号):

ESP32 排针位置参考

接线清单(带颜色 + 丝印)

🔌 接线表(务必确认共地)
模块引脚颜色连接目标丝印
MPU6050VCC红线ESP32 3.3V右侧排针第 1 脚,标 3V3
MPU6050GND黑线ESP32 GND右侧排针第 2 脚,标 GND
MPU6050SDA黄线ESP32 GPIO 21右侧排针第 11 脚,标 D21
MPU6050SCL白线ESP32 GPIO 22右侧排针第 14 脚,标 D22
MPU6050AD0接 GNDI2C 地址锁定为 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Ω 电阻)

⚠️ 关键提醒

物理结构:舵机小牌制作

用 5cm × 5cm 的硬卡纸(便签纸背面加厚的硬纸即可),裁成 3 张分别写上:

用双面胶把 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();
}

代码说明

调试步骤

按以下顺序逐步调试,每步验证通过再进下一步(避免一次接 8 根线发现全错)。

第 1 步:MPU6050 自检(3 分钟)

  1. 接线:仅接 MPU6050 的 4 根线(VCC/GND/SDA/SCL)
  2. 烧录 Arduino IDE 自带的 File → Examples → Adafruit MPU6050 → basic_readings 示例
  3. 打开串口监视器(115200 波特率),看到 Accel X: ... Y: ... Z: ... 持续滚动 → 接线正确
  4. Z 轴静止时应接近 9.8(重力),X/Y 轴接近 0;倾斜模块时数字应有明显变化
  5. 看不到数据 → 检查 I2C 地址(用 Scanner 扫,0x68 或 0x69)、SDA/SCL 是否接反

第 2 步:OLED 自检(3 分钟)

  1. 接线:OLED 4 根线全接(与 MPU6050 共用 I2C 总线)
  2. 烧录 File → Examples → Adafruit SSD1306 → ssd1306_128x64_i2c 示例
  3. 看到 OLED 显示一个跳动的方块 → 接线正确
  4. 看不到 → 检查 I2C 地址(用 Scanner 扫,0x3C 或 0x3D)、VCC/GND 接反

第 3 步:舵机自检(3 分钟)

  1. 接线:SG90 三根线接好(信号→D13,VCC→外部 5V,GND→ESP32 GND + 5V 电源负极共地)
  2. 烧录下面这段最小测试代码
  3. 看到舵机在 0° → 90° → 180° 之间循环 → 接线正确
  4. 舵机不动或抖动 → 检查 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 分钟)

  1. 接线:NPN S8050 基极经 1kΩ 电阻到 D12,集电极接蜂鸣器+,发射极接 GND
  2. 烧录:tone(12, 1000, 500); 听到「哔」一声 → 接线正确
  3. 听不到 → 检查三极管引脚(E/B/C 不要认错,集电极是接蜂鸣器那脚)

第 5 步:整合测试(10 分钟)

  1. 烧录上面的核心代码
  2. 打开串口监视器(115200 波特率)
  3. 应看到 OLED 大字倒计时(25:00 → 24:59 → ...),舵机 0° 收起
  4. 等到 24:55 时,蜂鸣器每秒嘀嗒
  5. 25:00 切换到 BREAK,舵机转到 90° 半举牌
  6. 5 分钟 BREAK 结束后,舵机转回 0°,重新开始 FOCUS
  7. 把 MPU6050 放桌上完全不动,等待 50 分钟(或者临时把 SIT_MINUTES 改成 1 测久坐)
  8. 应触发三声嘀嗒 + 180° 全举牌,OLED 底部红字「SIT TOO LONG」
  9. 拿起 MPU6050 晃动一下,舵机应在 30 秒后收回

常见故障

设计图

系统架构图(ESP32 中心 + 5 个外围)

🔧 系统架构图
        ┌──────────────────────┐
        │   ESP32 DevKit V1    │
        │   (主控 + 状态机)     │
        └──────────┬───────────┘
                   │
   ┌───────┬───────┼───────┬────────┐
   │       │       │       │        │
 GPIO13  I2C(21/22) GPIO12  5V+GND  蜂鸣器
   │       │       │        │       │
   ▼       ▼       ▼        ▼       ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌─────────┐ ┌──────┐
│SG90  │ │MPU6050│ │NPN   │ │ 外部5V  │ │有源  │
│舵机  │ │+OLED │ │S8050 │ │ 2A 电源 │ │蜂鸣器│
│(执行) │ │(感知+│ │(驱动) │ │(供电)   │ │(听觉)│
│      │ │ UI)  │ │      │ │         │ │      │
└──────┘ └──────┘ └──────┘ └─────────┘ └──────┘

番茄状态机流程图

🔄 番茄状态切换
当前状态触发条件切换到舵机动作
启动上电FOCUS #10° 收起
FOCUS倒计时 25:00 → 0:00BREAK 或 LONG_BREAK(每 4 轮)→ 90° 或 180°
BREAK倒计时 5:00 → 0:00FOCUS #n+1→ 0°
LONG_BREAK倒计时 15:00 → 0:00FOCUS #n+1→ 0°
任何状态MPU6050 检测久坐 ≥ 50 分钟不切换(叠加报警)→ 180°,30 秒后回原位

久坐检测逻辑

🧘 MPU6050 久坐判定流程
步骤判定动作
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 地址分配(SDA=21, SCL=22)
设备I2C 地址丝印 / 引脚配置
MPU60500x68AD0 接 GND(如 AD0 接 3.3V 则为 0x69)
SSD1306 OLED0x3C默认(部分模块是 0x3D)

扩展玩法

金句

"番茄钟是时间的便签,而 ESP32 让它站起来。"

本项目总成本 ¥84,比 Forest(¥18/年订阅)+ 番茄 ToDo 加起来还便宜,但获得了物理世界的真实反馈——你能听见舵机旋转的嗡嗡声、看见小牌被举起、感受到蜂鸣器的嘀嗒,而不只是屏幕上跳动的数字。久坐不再是无感知,因为你的桌面有一个 24 小时在线的「番茄小助手」。二次开发空间极大:番茄时长、久坐阈值、举牌文字都在代码顶部宏定义,改一行就能定制。动手做——让你的桌面会"说话"。