📋 專案概述
我姊是接案設計師,去年她做了一個實驗:用手機 App 計番茄鐘兩週、用實體番茄鐘計時器兩週、完全不計時兩週,比較各時段產出。結果很諷刺——用 App 的那兩週產出最差。原因她一句話講完:「我每按一次開始,就要解鎖手機一次;解鎖手機,就是悲劇的開始。」
工時統計軟體更荒謬:月費 300 元、要裝監控插件、資料不知道傳去哪裡。實體計時器呢?查了一圈,「會倒數的方塊」兩款看得入眼的都要 1,400~2,200 元,功能是把沙漏翻過來——連剩餘時間都沒有,更別說統計。
這個專案做一個不用碰手機的番茄鐘方塊,成本不到 300 元:
- 翻面 = 開始/暫停:拿起來翻個面就開跑,全程零螢幕、零解鎖、零通知誘惑
- 敲兩下 = 重置:開會被打斷?敲兩下丟回 25:00
- 2.13 吋電子紙大數字:不背光、不閃爍,餘光掃到就知道剩幾分鐘;停著不動零刷新零耗電
- 時間到:蜂鳴器三聲 + 畫面顯示「DONE! 翻面重置」
- 底部統計列:今日完成番茄數 + 專注總分鐘,一天結算不用 App
電子紙 + 翻轉手勢的組合,讓「開始專注」這個動作壓到跟翻沙漏一樣簡單——工具越不像電腦,越不會誘惑你玩它。
為什麼選 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-1 | 1 | ~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 + 公對母 ×2 | 11 | ~12 元 | 方塊內部空間小,10cm 剛好 |
| 合計 | ~236 元 | 同功能商業品 1,400 元起跳 |
🔌 接線圖
詳細接線
| ESP32-C6 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO2 / 3 / 4 / 5 | 電子紙 BUSY / RST / DC / CS | 避開 GPIO9(strapping)與 GPIO12~14(板載 LED/flash 域) |
| GPIO6 / 7 | 電子紙 SCK / MOSI | SPI2 軟體自選腳位,不佔用原生 SPI 引腳 |
| GPIO8 / 10 | MPU6050 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 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 方塊上段(電子紙框) | 1 | PLA | 白 | ~2.5 hr |
| 方塊下段(電池/電路艙) | 1 | PLA | 深灰 | ~3 hr |
| MPU6050 重心托盤 | 1 | PLA | 深灰 | ~20 min |
| 底部走線蓋 | 1 | PLA | 深灰 | ~15 min |
列印設定
- 材質:PLA 即可(桌面室內使用)
- 填滿:上段 20%(要有一點重量防翻倒)/其餘 15% 網格
- 層厚:0.2mm;電子紙框面 0.12mm + 4 頂層(展示面要漂亮)
- 特殊:上段「面朝上」列印,頂面零支撐,避免展示面留支撐痕
設計要點
- 65 mm 立方體:邊長 65mm 是「一手可握可翻」的上限,再大翻面手感就拖泥帶水
- IMU 鎖在幾何重心:托盤懸吊於上下段之間的十字肋上——偏心的 IMU 翻面時會產生「先小後大」的雙峰加速度,翻轉偵測誤差直接翻倍
- 電子紙嵌框 + 0.3mm 間隙:面板卡進上段內框,正面只露出顯示區;框緣高出面板 0.5mm,桌面拖曳不刮面板
- 底部蜂鳴器透音孔 6ר2mm:方塊翻面時頂面朝下,蜂鳴器孔要開在底部才不會被桌面悶住——這是「翻面計時中」還能被叫醒的關鍵
- 四角 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 組裝說明書
- 列印零件:上段面朝上列印(展示面零支撐)
- 固定電子紙:面板由內側卡入上段嵌框,四邊雙面膠加固,排線沿壁面整理
- 鎖 MPU6050 重心托盤:模組鎖在托盤正中央——晶片位置 ≈ 幾何重心,偏了翻面偵測會失準
- 接線:電子紙 8 條(SPI)、MPU6050 4 條(I2C)、蜂鳴器 2 條;全部先照接線表接好再通電
- 燒錄測試:USB-C 接電腦,板型選
ESP32C6 Dev Module,燒錄後電子紙應黑閃顯示 READY - 手勢校正:翻面、敲兩下各試 10 次,失準就調
FLIP_Z_MIN(漏判調小)與TAP_G(誤判調大) - 合蓋:上下段卡扣合攏、鎖 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() 差值扣時,不受迴圈抖動影響 |
🎯 進階延伸
- 電池版:402530 LiPo + TP4056 + 待機改 deep sleep(IMU INT 腳喚醒),電子紙零靜態耗電 + C6 睡眠 5µA,實測可撐 3~4 週
- Wi-Fi 6 日曆聯動:C6 連 Home Assistant,開會時段自動把番茄鐘鎖住(顯示 MEETING)——這就是選 C6 的佈局價值
- 六面功能面:四側面各印 15/25/45/60 分鐘,IMU 偵測「哪一面朝上」決定番茄長度
- 番茄日報:每日 18:00 經 Telegram Bot 推播今日專注統計(把
donePomos/doneMinsPOST 出去即可)
📦 檔案清單
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 # 說明文件