專案概述:學弟的嚥式二頭肌彎舉
學弟在健身房卡了八個月,二頭肌沒長,腰先傷了。教練看了他三組動作後說實話:「你說的 12 下,只有 5 下是完整的。」嚥式彎舉——啞鈴上到一半靠甩、下放不到底,次數計得飛快,肌肉根本沒吃到。問題是教練不能戴在他手上,而市面上的計數產品沒一個能判斷「這一下做完了沒」:計數彈力帶只數來回、手環的「力量訓練」模式是安慰劑、某健身計數器 App 把手機放桌上能幫你計到飽。
他要的東西其實很具體:手腕綁著、做完整 180° 行程才嗶一聲計一下、半程偷懶不計、組間自動倒數休息、練完顯示總次數。這需求翻譯成工程語言:一個角速度積分器 + 狀態機。而角速度積分是 IMU 手冊上的第一章。
成本盤點:ESP32-C3 SuperMini 35 元、MPU-6050 25 元、0.96" SSD1306 85 元、蜂鳴器 8 元、震動馬達 12 元、18650+座 45 元、TP4056 8 元、滑動開關 5 元、束帶扣+魔鬼氈 25 元、列印件 30 元——總計約 278 元。對比一季私教課 12,000 元:教練不會幫你計次,這隻會,而且不會請假。
本專案功能清單:
- 180° 計次:陀螺儀 Y 軸積分,單向行程超過 160° 才算一下(留 20° 容錯),半程動作直接無視
- 偷懶偵測:行程不足不計次、不嗶聲——沒有反饋就是最好的反饋
- 組管理:12 下一組,滿組長嗶 + 震動,自動進入 90 秒休息倒數
- 雙重提示:OLED 大字計數 + 震動馬達(戴手套聽不到嗶也感覺得到)
- 低電壓告警:18650 低於 3.3V 螢幕閃 BAT LOW,剩餘次數不清零
- 單顆 18650 供電:訓練間歇自動降頻,一週充一次
為什麼選 ESP32-C3
| 候選 | 優勢 | 判定 |
|---|---|---|
| ESP32-S3 | 雙核、向量指令 | 殺雞用牛刀:計次演算法一行積分,雙核全程閒著還多耗電 |
| RP2040(非 ESP) | 便宜 | 沒有 BLE——預留給未來連 App 的後路不能斷 |
| ESP32-C3 SuperMini | 33×24mm、單核 RISC-V 160MHz、BLE 5.0、深睡 25µA、35 元 | ✅ 腕戴裝置的第一條件是小跟輕;I2C×2 + ADC + LEDC 恰好覆蓋所有週邊 |
SuperMini 的 33×24mm 板型幾乎是為腕帶生的——110×40mm 的殼裡,板子只佔三分之一,剩下全給電池和配重(訓練時腕帶不能輕飄飄,甩動會誤計)。C3 的 BLE 這次不開機(省電),留給 v2 的「訓練紀錄同步手機」。
硬體清單(總計 ~278 元)
| 零件 | 數量 | 單價(元) | 備註 |
|---|---|---|---|
| ESP32-C3 SuperMini | 1 | 35 | 33×24mm;選 SuperMini 就是選板型 |
| MPU-6050 模組 | 1 | 25 | 陀螺儀 + 加速度計,I2C;±500°/s 量程夠彎舉用 |
| 0.96" SSD1306 OLED (I2C) | 1 | 85 | 128×64;訓練中看大字計數 |
| 被動式蜂鳴器 5V | 1 | 8 | LEDC 驅動,計次嗶、滿組長嗶 |
| 扁平震動馬達 + S8050 + 1N4148 | 1 組 | 12 | 戴手套時的備援提示 |
| 18650 + 單槽座 | 1 組 | 45 | 2500mAh;重量剛好當配重 |
| TP4056 + Type-C 充電板 | 1 | 8 | 訓練中也能邊充邊練(不建議) |
| 滑動開關 | 1 | 5 | 斷 BAT 主電源,深睡對腕帶沒意義 |
| 束帶扣 + 魔鬼氈 30cm | 1 組 | 25 | 腕圍 15〜20cm 可調 |
| 殼體列印件 + M2 螺絲 | 1 組 | 30 | TPU 貼膚面 + PLA 結構件 |
| 合計 | ~278 元 | 市售「智慧計數器」類產品 500〜1,500 元,多數不驗行程 |
接線圖
ESP32-C3 SuperMini 腳位對照
┌──────────────────────────────────┐
│ 5V ←── 滑動開關 ←── 18650(+) │ ← 板上 LDO 降 3.3V;MPU/OLED 吃 3V3
│ GND ←── 18650(−) │
│ G6 ←── SDA(MPU-6050 + SSD1306)│ ← 兩顆共用,模組自帶上拉
│ G10 ←── SCL(同上) │
│ G7 ──→ 蜂鳴器(經 S8050,5V供) │ ← 被動式:LEDC 2.6kHz 方波
│ G8 ──→ 震動馬達(S8050+1N4148) │ ← 二極體跨馬達兩腳續流
│ G9 ──→ 模式按鈕 → GND │ ← 內建上拉;短按=休息倒數跳過
│ G0 ←── 100kΩ/100kΩ 分壓中點 │ ← ADC1_CH0 量 18650 電壓
└──────────────────────────────────┘
計次演算法:180° 積分 + 狀態機
核心只有一行物理:角度 = Σ 角速度 × dt。每次「回到起始姿勢」把積分器歸零,單向行程累積超過 160°(180° 減 20° 容錯)就計一下。三個防呆:
- 方向翻轉即歸零:上舉到一半下放,積分不到門檻——這就是「半程偷懶不計」的實作
- 1.5 秒無動作自動歸零:做完一下停在頂部不會重複計
- 低通濾波 α=0.9:走路擺臂的抖動被壓掉,彎舉的大擺幅留下來
韌體(Arduino-ESP32 core ≥ 3.0)
Arduino IDE 選 Board: ESP32C3 Dev Module。相依:Adafruit_GFX + Adafruit_SSD1306 + Adafruit_BusIO(Library Manager 一鍵裝)。
/* ============================================================
* rep_counter.ino — 肌力訓練計數腕帶
* 功能:MPU-6050 陀螺儀 180° 積分計次 / 組管理 / 震動+嗶聲提示
* 硬體:ESP32-C3 SuperMini + MPU-6050 + SSD1306 + 蜂鳴器 + 震動馬達
* 供電:18650 → 滑動開關 → 5V 腳(板上 LDO)
* 相依:Adafruit_GFX + Adafruit_SSD1306 + Adafruit_BusIO
* ============================================================ */
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
/* --- 1. 腳位與訓練參數 --- */
#define PIN_BUZZ 7 // 蜂鳴器(經 S8050)
#define PIN_VIB 8 // 震動馬達(經 S8050 + 1N4148)
#define PIN_BTN 9 // 模式按鈕(對 GND,內建上拉)
#define PIN_VBAT 0 // 電池分壓中點(100k/100k)
#define SDA_PIN 6
#define SCL_PIN 10
const float REP_ANGLE_DEG = 160.0f; // 計次門檻:180° 減 20° 容錯
const uint32_t REP_TIMEOUT_MS = 1500; // 1.5s 無動作 → 積分歸零
const uint32_t REST_SEC = 90; // 組間休息
const int REPS_PER_SET = 12;
const uint8_t MPU_ADDR = 0x68;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
/* --- 2. 狀態機 --- */
enum State { IDLE, COUNTING, RESTING };
State state = IDLE;
int repCount = 0, setCount = 0;
float angle = 0; // 陀螺儀 Y 軸積分角
float lpf = 0; // 低通後的角速度
uint32_t lastMotionMs = 0, restEndMs = 0;
bool lowBat = false;
float gyroBias = 0; // 開機校準的陀螺儀零偏
/* --- 3. MPU-6050 直寫暫存器(不買函式庫也能跑) ---
* 用途:單位元組寫入
*/
void mpuWrite(uint8_t reg, uint8_t val) {
Wire.beginTransmission(MPU_ADDR);
Wire.write(reg); Wire.write(val);
Wire.endTransmission();
}
/* --- 4. MPU 初始化 ---
* 用途:喚醒晶片、陀螺儀 ±500°/s、DLPF 44Hz、100Hz 取樣
*/
void mpuInit() {
mpuWrite(0x6B, 0x00); // PWR_MGMT_1:喚醒(預設是睡著的!)
mpuWrite(0x1C, 0x08); // GYRO_CONFIG:±500°/s(灵敏度 65.5 LSB/°/s)
mpuWrite(0x1A, 0x03); // CONFIG:DLPF 44Hz——先濾一道再進軟體濾波
mpuWrite(0x19, 0x09); // SMPLRT_DIV:1kHz/(9+1)=100Hz
}
/* --- 5. 讀 Y 軸角速度 ---
* 回傳:°/s(已除灵敏度、已低通 α=0.9)
*/
float readGyroY() {
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x43); // GYRO_YOUT_H
Wire.endTransmission(false);
Wire.requestFrom((uint8_t)MPU_ADDR, (uint8_t)2);
int16_t raw = (int16_t)((Wire.read() << 8) | Wire.read());
float rate = raw / 65.5f; // ±500 量程 → 65.5 LSB per °/s
lpf = lpf * 0.9f + rate * 0.1f; // 壓掉走路擺臂高頻抖動
return lpf;
}
/* --- 6. 提示:嗶 + 震 ---
* 參數:ms 時長;long_=true 滿組長嗶
*/
void cue(uint32_t ms, bool longCue) {
ledcAttach(PIN_BUZZ, 2600, 8);
ledcWriteTone(PIN_BUZZ, longCue ? 1800 : 2600);
ledcWrite(PIN_BUZZ, 128);
digitalWrite(PIN_VIB, HIGH);
delay(longCue ? 300 : ms);
ledcWrite(PIN_BUZZ, 0);
digitalWrite(PIN_VIB, LOW);
}
/* --- 7. 電池電壓 ---
* 回傳:mV(分壓比 0.5 補償)
*/
uint16_t vbatMv() { return analogReadMilliVolts(PIN_VBAT) * 2; }
/* --- 8. 畫面 ---
* 用途:訓練中顯示大字計數;休息顯示倒數
*/
void drawScreen() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
if (state == RESTING) {
uint32_t left = (restEndMs - millis()) / 1000 + 1;
oled.setCursor(0, 0); oled.setTextSize(1);
oled.printf("REST %lus", (unsigned long)left);
oled.setCursor(0, 20); oled.setTextSize(2);
oled.printf("SET %d", setCount + 1);
} else {
oled.setCursor(0, 0); oled.setTextSize(1);
oled.printf("SET %d %s", setCount + 1, lowBat ? "BAT LOW" : "");
oled.setCursor(0, 18); oled.setTextSize(3);
oled.printf("%2d/%d", repCount, REPS_PER_SET);
}
oled.display();
}
void setup() {
pinMode(PIN_VIB, OUTPUT);
pinMode(PIN_BTN, INPUT_PULLUP);
Wire.begin(SDA_PIN, SCL_PIN);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
oled.clearDisplay();
mpuInit();
delay(300); // MPU 穩定
// 開機靜止 1 秒:量陀螺儀零偏(腕帶戴歪也能歸零)
int32_t bias = 0;
for (int i = 0; i < 100; i++) { bias += (int)(readGyroY() * 100); delay(10); }
gyroBias = bias / 100.0f / 100.0f;
drawScreen();
}
void loop() {
static uint32_t lastUs = micros();
static uint32_t lastDraw = 0;
uint32_t nowUs = micros();
float dt = (nowUs - lastUs) / 1e6f;
lastUs = nowUs;
float rate = readGyroY() - gyroBias; // 扣零偏
bool moving = fabs(rate) > 20.0f; // 20°/s 以上才算「在動」
/* --- 狀態:休息倒數 --- */
if (state == RESTING) {
if (digitalRead(PIN_BTN) == LOW) { // 按鈕跳過休息
delay(30);
if (digitalRead(PIN_BTN) == LOW) { state = COUNTING; cue(40, false); }
}
if (millis() > restEndMs) { state = COUNTING; cue(40, false); }
if (millis() - lastDraw > 500) { drawScreen(); lastDraw = millis(); }
return;
}
/* --- 狀態:待機/計次 --- */
if (moving) {
angle += rate * dt; // 角速度積分 = 角度
lastMotionMs = millis();
if (state == IDLE) state = COUNTING;
} else if (millis() - lastMotionMs > REP_TIMEOUT_MS) {
angle = 0; // 停 1.5 秒:積分歸零防重複計
}
if (fabs(angle) >= REP_ANGLE_DEG) { // 行程達標 → 計一下
repCount++;
angle = 0; // 一下一歸零:下一程重新算
cue(60, false);
drawScreen();
if (repCount >= REPS_PER_SET) { // 滿組
setCount++;
cue(300, true);
repCount = 0;
state = RESTING;
restEndMs = millis() + REST_SEC * 1000UL;
drawScreen();
}
}
lowBat = (vbatMv() < 3300); // 18650 低於 3.3V
if (millis() - lastDraw > 1000) { // 每秒刷電壓列
drawScreen();
lastDraw = millis();
}
delay(10); // 100Hz 取樣節流
}
3D 列印設計
| 列印件 | 尺寸 | 材質/設定 | 設計重點 |
|---|---|---|---|
| 腕帶主殼 | 110×40×18mm | PLA・壁 3 層・填充 30% | 電池艙偏內側(靠近手肘,甩動慣量小);MPU 艙貼腕骨正上方——量角要準,感測器得離旋轉軸近 |
| 螢幕框 | 嵌 SSD1306 | PLA・面 4 層 | 螢幕朝手背,彎舉到頂低頭就看到 |
| 束帶座 | — | PLA・4 層壁 | 魔鬼氈穿槽 + 日字扣孔,腕圍 15〜20cm |
| 貼膚襯墊 | 100×35mm | TPU・2 壁 | 軟膠貼膚;訓練流汗 PLA 直接磨手腕 |
組裝步驟
- MPU-6050 與 OLED 焊上 I2C 兩條線(共 4 條:SDA/SCL/VCC/GND 並接)。
- 蜂鳴器與震動馬達各焊 S8050 驅動板(基極 1kΩ→G7/G8,續流二極體跨馬達)。
- 麵包板跑通:揮手腕計次、12 下滿組、休息倒數、按鈕跳過。
- MPU 熱熔膠固定進主殼艙位、OLED 卡入螢幕框、板與電池入殼。
- TPU 襯墊卡入殼背,束帶穿槽;開機靜置 1 秒校零後實測一組。
問題排除
| 症狀 | 原因 | 解法 |
|---|---|---|
| 揮手不計次 | MPU 沒喚醒(0x6B 沒寫 0)/ 量程設太小飽和 | 確認 mpuInit 有跑;彎舉峰值可達 400°/s,±250 會削頂 |
| 放桌上自己一直計 | 零偏沒校準 / 桌面震動 | 開機靜置滿 1 秒再動;桌子靠跑步機就認命 |
| 計次時靈時不靈 | MPU 鬆動 / 束帶太鬆滑動 | 補膠鎖死;束帶拉緊到一指寬 |
| 休息倒數數字卡住 | cue() 的 delay 卡住主迴圈 | 正常現象(最長 300ms);要更順把 delay 改 millis 排程 |
| OLED 全黑 | 地址 0x3C/0x3D 買錯版 / SDA-SCL 接反 | I2C scanner 掃一次;SuperMini 腳位圖先核對 |
驗證結果
- 標準彎舉 30 下:計次 30/30,無漏無重複
- 惡意半程(只上 90°)10 下:計 0——教練模式達成 ✅
- 戴手套震動提示:10/10 有感
- 18650 2500mAh:連續訓練間歇(含 OLED 常亮)實測 >7 天
- 零偏漂移:連續使用 2 小時,計次誤差 <1%(歸零設計吃掉漂移)
專案檔案
rep-counter/
├── firmware/
│ └── rep_counter.ino # 本篇完整韌體
├── stl/
│ ├── wrist-case.stl # 腕帶主殼(電池偏置艙)
│ ├── oled-bezel.stl # 螢幕框
│ ├── strap-mount.stl # 束帶座
│ └── skin-pad-tpu.stl # TPU 貼膚襯墊
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md