📋 專案概述

我姊是接案設計師,去年她做了一個實驗:用手機 App 計番茄鐘兩週、用實體番茄鐘計時器兩週、完全不計時兩週,比較各時段產出。結果很諷刺——用 App 的那兩週產出最差。原因她一句話講完:「我每按一次開始,就要解鎖手機一次;解鎖手機,就是悲劇的開始。」

工時統計軟體更荒謬:月費 300 元、要裝監控插件、資料不知道傳去哪裡。實體計時器呢?查了一圈,「會倒數的方塊」兩款看得入眼的都要 1,400~2,200 元,功能是把沙漏翻過來——連剩餘時間都沒有,更別說統計。

這個專案做一個不用碰手機的番茄鐘方塊,成本不到 300 元:

電子紙 + 翻轉手勢的組合,讓「開始專注」這個動作壓到跟翻沙漏一樣簡單——工具越不像電腦,越不會誘惑你玩它。

為什麼選 ESP32-C6?

需求C6 的優勢
腳位需求多電子紙 6 支 GPIO + I2C 2 支 + 蜂鳴器 1 支,C6 的 GPIO 數量比 C2 從容,還避開 strapping 腳綽綽有餘
低功耗潛力電子紙「顯示後不耗電」+ C6 深度睡眠 ~5 µA:本專案先跑 USB,日後改電池 + 翻轉喚醒可撐數週
Wi-Fi 6 佈局今日離線可用,日後要接 Home Assistant / 日曆自動免打擾模式,C6 的 Wi-Fi 6 + TWT 是面向未來的本錢
單核夠用160 MHz RISC-V 單核跑「50ms 輪詢 IMU + 每 10 秒畫一次電子紙」,負載連 1% 都不到

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C6 SuperMini / DevKitM-11~55 元需引出 GPIO2~8、10 的版型
顯示2.13 吋電子紙(SSD1680,250×122)1~120 元選「樹莓派 HAT 規格 4.2pin + SPI 轉接」或直接 SPI 7pin 版
慣性感測MPU6050 模組(GY-521)1~25 元附排針版;ITG3205 舊版晶片會開機失敗,認 GY-521
蜂鳴器有源蜂鳴器 3–5V(高電平觸發)1~6 元被動式需自己出波,用有源省事
電源USB-C 線 + 5V 充電頭1~10 元舊手機充電頭即可
固定M2×8 螺絲 + 銅柱8 組~8 元電子紙用雙面膠,MPU6050 用螺絲(重心要穩)
導線杜邦線 10cm 母對母 ×9 + 公對母 ×211~12 元方塊內部空間小,10cm 剛好
合計~236 元同功能商業品 1,400 元起跳

🔌 接線圖

ESP32-C6 SuperMini / DevKitM-1 USB-C(供電+燒錄) GPIO2 BUSY GPIO3 RST GPIO4 DC GPIO5 CS GPIO6 SCK GPIO7 MOSI GPIO8 SDA GPIO10 SCL 24:59 2.13" 電子紙 SSD1680 CS·DC·RST·BUSY·SCK·MOSI·3V3·GND MPU6050(置於重心) SDA SCL 蜂鳴器(+ 經 1kΩ → GPIO1) 蜂鳴器「−」接 GND、3V3 供 +(或 GPIO 直驅 +/GND −,30mA 內)
▲ 電子紙走 SPI(黃)、MPU6050 走 I2C(藍)、蜂鳴器一支 GPIO。全部共 3.3V,勿接 5V 到電子紙。

詳細接線

ESP32-C6 腳位連接說明
GPIO2 / 3 / 4 / 5電子紙 BUSY / RST / DC / CS避開 GPIO9(strapping)與 GPIO12~14(板載 LED/flash 域)
GPIO6 / 7電子紙 SCK / MOSISPI2 軟體自選腳位,不佔用原生 SPI 引腳
GPIO8 / 10MPU6050 SDA / SCL模組自带上拉,不必外加電阻
GPIO1蜂鳴器 +(經 1kΩ)− 接 GND;有源蜂鳴器 HIGH = 叫
3V3 / GND兩模組電源電子紙絕對不要接 5V,邏輯上限 3.3V
ESP32-C6 2.13" e-Paper (SSD1680) MPU6050 (GY-521) ┌──────────┐ ┌────────────────┐ ┌──────────┐ │ GPIO5 ●────────● CS │ │ VCC ●── 3V3 │ GPIO4 ●────────● DC │ │ GND ●── GND │ GPIO3 ●────────● RST │ │ SDA ●── GPIO8 │ GPIO2 ●────────● BUSY │ │ SCL ●── GPIO10 │ GPIO6 ●────────● SCK │ │ (時鐘) (本專案不用 INT) │ GPIO7 ●────────● MOSI(DIN) │ └──────────┘ │ 3V3 ●────────● VCC (3.3V!) │ │ GND ●────────● GND │ 蜂鳴器: GPIO1 ─1kΩ─▶ (+) (−)─▶ GND └──────────┘ └────────────────┘
⚠️ 電子紙供電接 5V 會直接損傷面板(不可逆的灰斑)。接線完成後先用萬用表確認 VCC 腳實際是 3.3V 再插上。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
方塊上段(電子紙框)1PLA白~2.5 hr
方塊下段(電池/電路艙)1PLA深灰~3 hr
MPU6050 重心托盤1PLA深灰~20 min
底部走線蓋1PLA深灰~15 min

列印設定

設計要點

  1. 65 mm 立方體:邊長 65mm 是「一手可握可翻」的上限,再大翻面手感就拖泥帶水
  2. IMU 鎖在幾何重心:托盤懸吊於上下段之間的十字肋上——偏心的 IMU 翻面時會產生「先小後大」的雙峰加速度,翻轉偵測誤差直接翻倍
  3. 電子紙嵌框 + 0.3mm 間隙:面板卡進上段內框,正面只露出顯示區;框緣高出面板 0.5mm,桌面拖曳不刮面板
  4. 底部蜂鳴器透音孔 6ר2mm:方塊翻面時頂面朝下,蜂鳴器孔要開在底部才不會被桌面悶住——這是「翻面計時中」還能被叫醒的關鍵
  5. 四角 TPU 墊腳(選印):敲兩下重置時防滑、減震,減少誤判

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
 *  ESP32-C6 番茄鐘專注方塊  |  Pomodoro Focus Cube
 *  ------------------------------------------------------------
 *  功能:翻面 = 開始/暫停、敲兩下 = 重置;2.13" 電子紙倒數,
 *        時間到蜂鳴三聲並顯示 DONE;底部統計今日番茄數與分鐘
 *  硬體:ESP32-C6 SuperMini + 2.13in SSD1680 e-paper
 *        + MPU6050(GY-521) + 有源蜂鳴器
 *  供電:USB-C 5V(電池版見文章「進階延伸」)
 *  相依:Arduino core for ESP32 ≥ 3.3、Adafruit GFX、
 *        Adafruit SSD16xx、Adafruit MPU6050、Adafruit Unified Sensor
 *  板型:Board: "ESP32C6 Dev Module"
 *  ============================================================ */

#include <Wire.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD16xx.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>

/* ------------------------------------------------------------
 * 1. 腳位與番茄鐘參數
 * ------------------------------------------------------------ */
#define EPD_CS    5
#define EPD_DC    4
#define EPD_RST   3
#define EPD_BUSY  2
#define EPD_SCK   6
#define EPD_MOSI  7
const int BUZZER_PIN = 1;

const uint32_t POMODORO_MS   = 25UL * 60UL * 1000UL;  // 一顆番茄 25 分鐘
const uint8_t  PARTIAL_EVERY_S = 10;   // 倒數每 10 秒部分刷新:電子紙部分刷新有殘影,
                                       // 但 1 秒一刷會閃且縮短面板壽命,10 秒是餘光可讀與壽命的平衡
const uint16_t FULL_EVERY_S    = 300;  // 每 5 分鐘一次全刷新清除累積殘影

/* 手勢偵測閾值(g) */
const float  FLIP_Z_MIN     = 0.65;   // 判定「翻面」:|az| 至少 0.65g(接近正翻 180°)
const float  FLIP_XY_MAX    = 0.45;   // 且 |ax|,|ay| 小:排除斜拿、走動、抖桌
const float  TAP_G          = 1.9;    // 敲擊瞬間合成加速度 > 1.9g(靜止 1g、輕敲 2~3g)
const uint16_t TAP_WINDOW_MS  = 450;  // 兩擊間隔上限:超過視為兩次獨立敲擊
const uint16_t GESTURE_LOCK_MS = 1500; // 手勢冷卻:一次翻面的振盪只算一件事

/* ------------------------------------------------------------
 * 2. 狀態機
 * ------------------------------------------------------------ */
enum State { ST_IDLE, ST_RUNNING, ST_PAUSED, ST_DONE };
State state = ST_IDLE;

uint32_t remainMs   = POMODORO_MS;  // 剩餘時間(暂停即凍結此值)
uint32_t lastTickMs = 0;            // 上次倒數基準(millis 差值,不用 delay 計時)
uint16_t donePomos  = 0;            // 今日完成顆數
uint16_t doneMins   = 0;            // 今日專注分鐘
float    stableAz   = 1.0;          // 上一穩定姿態的 Z 軸符號(1.0 = 電子紙朝上)
uint32_t lastGestureMs = 0;         // 手勢冷卻計時
uint32_t lastTapMs     = 0;         // 上一擊時間
uint16_t lastShownSec  = 0xFFFF;    // 上次畫到畫面的秒數(0xFFFF = 強制重畫)
bool     partialSinceFull = false;  // 自上次全刷新後是否做過部分刷新

Adafruit_SSD1680 epd(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY, &SPI);
Adafruit_MPU6050 mpu;
sensors_event_t accel;

/* ------------------------------------------------------------
 * drawScreen —— 重繪整頁(大字倒數 + 狀態 + 今日統計)
 *   參數:full = true 全刷新(有黑閃,狀態切換時用)
 *               false 部分刷新(無閃,倒數中間更新用)
 *   為什麼自己排版:電子紙沒有「區域清除」概念,
 *   每次都要重畫整頁 buffer,函式必須冪等
 * ------------------------------------------------------------ */
void drawScreen(bool full) {
  epd.clearBuffer();

  // 主數字區:mm:ss,16pt 粗體置中
  uint32_t sec = (remainMs + 999) / 1000;
  char buf[8];
  snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)(sec / 60), (unsigned)(sec % 60));
  epd.setTextFont(&FreeSansBold16pt7b);
  epd.setCursor(78, 62);
  epd.print(buf);

  // 狀態列
  epd.setTextFont(&FreeSans9pt7b);
  epd.setCursor(8, 22);
  switch (state) {
    case ST_IDLE:     epd.print("READY  flip to start"); break;
    case ST_RUNNING:  epd.print("FOCUS"); break;
    case ST_PAUSED:   epd.print("PAUSED flip to resume"); break;
    case ST_DONE:     epd.print("DONE!  flip to reset"); break;
  }

  // 底部統計列
  epd.setCursor(8, 116);
  epd.print("Today: ");
  epd.print(donePomos);
  epd.print(" pomos / ");
  epd.print(doneMins);
  epd.print(" min");

  epd.displayBuffer(full ? HW_REFRESH_FULL : HW_REFRESH_PARTIAL);
  if (full) partialSinceFull = false; else partialSinceFull = true;
  lastShownSec = sec;
}

/* ------------------------------------------------------------
 * beep —— 非阻塞以外的簡單蜂鳴(只在狀態切換呼叫,
 *   每次 <= 1.2 秒,不影響手勢輪詢的即時性)
 *   參數:n = 嗶數(200ms 響 / 150ms 停)
 * ------------------------------------------------------------ */
void beep(int n) {
  for (int i = 0; i < n; i++) {
    digitalWrite(BUZZER_PIN, HIGH); delay(200);
    digitalWrite(BUZZER_PIN, LOW);  delay(150);
  }
}

/* ------------------------------------------------------------
 * handleGesture —— 從 IMU 讀值判斷翻面 / 雙敲
 *   翻面:az 符號翻轉且接近純 Z 軸(排除斜拿與走動)
 *   雙敲:兩次 >1.9g 脈衝、間隔 < 450ms
 *   回傳:1 = 翻面事件、2 = 雙敲事件、0 = 無
 *   為什麼 50ms 取樣:翻面持續 >300ms,50ms 足夠抓到平台期;
 *   敲擊脈衝 ~50ms,取樣再慢就會漏第二擊
 * ------------------------------------------------------------ */
int handleGesture(float ax, float ay, float az) {
  uint32_t now = millis();
  if (now - lastGestureMs < GESTURE_LOCK_MS) return 0;  // 冷卻期直接忽略

  // --- 雙敲偵測(先於翻面:敲擊時 az 通常不翻轉,不會互相搶事件) ---
  float gmag = sqrt(ax * ax + ay * ay + az * az);
  if (gmag > TAP_G) {
    if (lastTapMs && (now - lastTapMs) < TAP_WINDOW_MS) {
      lastTapMs = 0; lastGestureMs = now;
      return 2;
    }
    lastTapMs = now;
    return 0;
  }

  // --- 翻面偵測:姿態「穩定」在反面或正面才認列 ---
  bool flatZ = (fabs(az) > FLIP_Z_MIN) && (fabs(ax) < FLIP_XY_MAX) && (fabs(ay) < FLIP_XY_MAX);
  if (flatZ) {
    float azSign = (az > 0) ? 1.0 : -1.0;
    if (azSign != stableAz) {          // 與上一穩定姿態符號相反
      stableAz = azSign;
      lastGestureMs = now;
      return 1;
    }
  }
  return 0;
}

/* ------------------------------------------------------------
 * onFlip —— 翻面事件的狀態轉移
 * ------------------------------------------------------------ */
void onFlip() {
  beep(1);
  switch (state) {
    case ST_IDLE:
      state = ST_RUNNING;
      lastTickMs = millis();
      break;
    case ST_RUNNING:
      state = ST_PAUSED;               // 暫停:remainMs 凍結在最後扣減值
      break;
    case ST_PAUSED:
      state = ST_RUNNING;
      lastTickMs = millis();           // 重設計時基準:暫停期間不扣時間
      break;
    case ST_DONE:
      state = ST_IDLE;                 // 完成頁翻面 → 重置下一顆
      remainMs = POMODORO_MS;
      break;
  }
  drawScreen(true);                    // 狀態切換一律全刷新(黑閃 = 明確回饋)
}

/* ------------------------------------------------------------
 * onDoubleTap —— 雙敲事件:任何狀態都丟回 25:00 待命
 * ------------------------------------------------------------ */
void onDoubleTap() {
  beep(2);
  state = ST_IDLE;
  remainMs = POMODORO_MS;
  drawScreen(true);
}

/* ------------------------------------------------------------
 * tickTimer —— 依 millis 差值扣時間
 *   用差值而非「每迴圈減 50ms」:IMU 讀取偶發延遲不會讓
 *   時鐘越走越慢(誤差只來自 millis() 本身,一天 < 1 秒)
 * ------------------------------------------------------------ */
void tickTimer() {
  if (state != ST_RUNNING) return;
  uint32_t now = millis();
  remainMs -= (now - lastTickMs);
  lastTickMs = now;

  if (remainMs <= 0) {
    remainMs = 0;
    state = ST_DONE;
    donePomos++;
    doneMins += POMODORO_MS / 60000UL;
    drawScreen(true);
    beep(3);                            // 三長音:人在隔壁房也分得出「完成 vs 暫停」
    return;
  }

  // 每 10 秒部分刷新;每 5 分鐘改全刷新清殘影
  uint32_t sec = (remainMs + 999) / 1000;
  bool needDraw = (sec % PARTIAL_EVERY_S == 0) && (sec != lastShownSec);
  bool forceFull = (sec % FULL_EVERY_S == 0) && partialSinceFull;
  if (needDraw) drawScreen(forceFull);
}

/* ------------------------------------------------------------
 * setup —— 開機:蜂鳴器 → 電子紙(含重啟保護)→ IMU → 首畫
 * ------------------------------------------------------------ */
void setup() {
  Serial.begin(115200);
  pinMode(BUZZER_PIN, OUTPUT);
  digitalWrite(BUZZER_PIN, LOW);

  SPI.begin(EPD_SCK, -1, EPD_MOSI, EPD_CS);   // SPI2 軟體腳位:SCK/MOSI/CS
  epd.begin(EPD_2IN13_V3);
  delay(100);                                  // 面板上電穩定(急會白屏)

  Wire.begin(8, 10);                           // SDA=GPIO8, SCL=GPIO10
  for (int i = 0; i < 5 && !mpu.begin(&Wire); i++) delay(200);
  // 逾時重試:GY-521 上電偶發 I2C busy,重試 5 次仍失敗時
  // mpu 保持未初始化,loop 讀值會被 getEvent 的 false 回傳擋掉
  mpu.setAccelerometerRange(MPU6050_RANGE_8_G); // 8g:敲擊峰值 2~3g,留足餘裕不削頂
  mpu.setFilterBandwidth(MPU6050_BAND_94_HZ);   // 94Hz 低通:濾掉桌面鍵盤震動高頻

  drawScreen(true);
}

/* ------------------------------------------------------------
 * loop —— 50ms 節奏:讀 IMU → 手勢 → 倒數
 * ------------------------------------------------------------ */
void loop() {
  sensors_event_t a, m, temp;
  if (mpu.getEvent(&a, &m, &temp)) {           // 讀取失敗直接跳過本輪,不卡死
    int g = handleGesture(a.acceleration.x, a.acceleration.y, a.acceleration.z);
    if (g == 1) onFlip();
    else if (g == 2) onDoubleTap();
  }
  tickTimer();
  delay(50);
}

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印零件:上段面朝上列印(展示面零支撐)
  2. 固定電子紙:面板由內側卡入上段嵌框,四邊雙面膠加固,排線沿壁面整理
  3. 鎖 MPU6050 重心托盤:模組鎖在托盤正中央——晶片位置 ≈ 幾何重心,偏了翻面偵測會失準
  4. 接線:電子紙 8 條(SPI)、MPU6050 4 條(I2C)、蜂鳴器 2 條;全部先照接線表接好再通電
  5. 燒錄測試:USB-C 接電腦,板型選 ESP32C6 Dev Module,燒錄後電子紙應黑閃顯示 READY
  6. 手勢校正:翻面、敲兩下各試 10 次,失準就調 FLIP_Z_MIN(漏判調小)與 TAP_G(誤判調大)
  7. 合蓋:上下段卡扣合攏、鎖 4 顆 M2 螺絲,貼 TPU 墊腳

🔧 問題排除

問題原因解決方法
電子紙白屏/黑屏排線接錯或 BUSY 無回應核對 8 條線;BUSY 懸空會永遠卡在等待,量 GPIO2 有無電壓變化
畫面殘影越積越髒長期只部分刷新本程式每 5 分鐘自動全刷;手動觸發:翻面進 DONE 再翻回即全刷
MPU6050 開機找不到ITG3205 假貨 / 地址不是 0x68換 GY-521 正版;或改 mpu.begin(0x68, &Wire) 指定地址
走路帶著走一直觸發XY 閾值太鬆FLIP_XY_MAX 從 0.45 調嚴到 0.35;FLIP_Z_MIN 調高到 0.75
敲鍵盤就重置桌面震動傳到 IMU加印 TPU 墊腳;TAP_G 調高到 2.2;IMU 務必鎖緊在托盤(懸空會放大震動)
倒數時間越跑越慢用「每迴圈減 50ms」的寫法本程式已用 millis() 差值扣時,不受迴圈抖動影響

🎯 進階延伸

📦 檔案清單

focus-cube/
├── src/
│   └── focus-cube.ino           # ESP32 程式碼(本頁完整程式)
├── stl/
│   ├── cube-top.stl             # 上段(電子紙框)
│   ├── cube-bottom.stl          # 下段(電路艙)
│   ├── imu-tray.stl             # IMU 重心托盤
│   └── bottom-cover.stl         # 底部走線蓋
├── f3d/
│   └── focus-cube-design.f3d    # Fusion 360 原始檔
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32-C2 水槽下方漏水偵測器 📚 系列索引 下一篇:ESP32-C3 智能護眼桌燈 →