專案概述:學弟的嚥式二頭肌彎舉

學弟在健身房卡了八個月,二頭肌沒長,腰先傷了。教練看了他三組動作後說實話:「你說的 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 元:教練不會幫你計次,這隻會,而且不會請假。

本專案功能清單:

為什麼選 ESP32-C3

候選優勢判定
ESP32-S3雙核、向量指令殺雞用牛刀:計次演算法一行積分,雙核全程閒著還多耗電
RP2040(非 ESP)便宜沒有 BLE——預留給未來連 App 的後路不能斷
ESP32-C3 SuperMini33×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 SuperMini13533×24mm;選 SuperMini 就是選板型
MPU-6050 模組125陀螺儀 + 加速度計,I2C;±500°/s 量程夠彎舉用
0.96" SSD1306 OLED (I2C)185128×64;訓練中看大字計數
被動式蜂鳴器 5V18LEDC 驅動,計次嗶、滿組長嗶
扁平震動馬達 + S8050 + 1N41481 組12戴手套時的備援提示
18650 + 單槽座1 組452500mAh;重量剛好當配重
TP4056 + Type-C 充電板18訓練中也能邊充邊練(不建議)
滑動開關15斷 BAT 主電源,深睡對腕帶沒意義
束帶扣 + 魔鬼氈 30cm1 組25腕圍 15〜20cm 可調
殼體列印件 + M2 螺絲1 組30TPU 貼膚面 + PLA 結構件
合計~278 元市售「智慧計數器」類產品 500〜1,500 元,多數不驗行程

接線圖

C3 SuperMini 5V ● 3V3 ● GND ● G6 ● G10 ● G7 ● G8 ● G9 ● ● G0 (ADC) 18650 + TP4056 3.7V → 滑動開關 → 5V 開關後 → 5V 腳 MPU-6050 SDA / SCL SSD1306 同 I2C 匯流排 SDA→G6 SCL→G10 VCC→3V3(兩顆都是) 蜂鳴器(5V)+S8050 G7 → 基極電阻 1kΩ 震動馬達+S8050 G8 → 1kΩ(+1N4148 續流) 模式按鈕 → G9+GND 100kΩ 100kΩ G0:電池分壓中點
接線圖:兩顆 I2C 共匯流排(地址不同:0x68 / 0x3C);電池經開關吃 5V 腳走板上 LDO
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 電壓
└──────────────────────────────────┘
⚠️ MPU-6050 吃 5V 還是 3V3?模組板載 LDO 所以 VCC 接 5V 也能跑,但 I2C 上拉跟著 VCC 走會把 5V 灌進 C3 的腳。本專案兩顆 I2C 裝置全部改吃 3V3,實測 MPU 供電穩定、OLED 亮度足夠。

計次演算法:180° 積分 + 狀態機

核心只有一行物理:角度 = Σ 角速度 × dt。每次「回到起始姿勢」把積分器歸零,單向行程累積超過 160°(180° 減 20° 容錯)就計一下。三個防呆:

待機 IDLE 感測到動作 計次中 積分+門檻判斷 滿 12 下 組間休息 90s 倒數 倒數結束/按鈕跳過 → 計數歸零開下一組
狀態機:低電壓告警不獨立成狀態,任何狀態下都疊加顯示

韌體(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 取樣節流
}
💡 為什麼不用現成 IMU 函式庫做姿態解算:MPU6050 庫 + DMP 解四元數是殺雞牛刀——計次只要「單軸轉了多少度」,直接積分陀螺儀單軸最省、延遲最低(10ms 級)。零偏漂移靠「每一下歸零」天然消除,這是本演算法最漂亮的設計:誤差活不過一次反覆。

3D 列印設計

列印件尺寸材質/設定設計重點
腕帶主殼110×40×18mmPLA・壁 3 層・填充 30%電池艙偏內側(靠近手肘,甩動慣量小);MPU 艙貼腕骨正上方——量角要準,感測器得離旋轉軸近
螢幕框嵌 SSD1306PLA・面 4 層螢幕朝手背,彎舉到頂低頭就看到
束帶座—PLA・4 層壁魔鬼氈穿槽 + 日字扣孔,腕圍 15〜20cm
貼膚襯墊100×35mmTPU・2 壁軟膠貼膚;訓練流汗 PLA 直接磨手腕
⚠️ MPU-6050 必須鎖死在殼內:任何相對殼體的鬆動都會被積分成「角度」。用熱熔膠滿塗底艙,螺絲固定後再點膠——本專案 90% 的計不準都是 MPU 沒鎖緊。

組裝步驟

  1. MPU-6050 與 OLED 焊上 I2C 兩條線(共 4 條:SDA/SCL/VCC/GND 並接)。
  2. 蜂鳴器與震動馬達各焊 S8050 驅動板(基極 1kΩ→G7/G8,續流二極體跨馬達)。
  3. 麵包板跑通:揮手腕計次、12 下滿組、休息倒數、按鈕跳過。
  4. MPU 熱熔膠固定進主殼艙位、OLED 卡入螢幕框、板與電池入殼。
  5. TPU 襯墊卡入殼背,束帶穿槽;開機靜置 1 秒校零後實測一組。
📘 焊點位置、S8050 三板接法、束帶穿法圖解,見圖文版:《計數腕帶 組裝說明書》(IKEA 風格 6 步卡片,可 A4 列印)。

問題排除

症狀原因解法
揮手不計次MPU 沒喚醒(0x6B 沒寫 0)/ 量程設太小飽和確認 mpuInit 有跑;彎舉峰值可達 400°/s,±250 會削頂
放桌上自己一直計零偏沒校準 / 桌面震動開機靜置滿 1 秒再動;桌子靠跑步機就認命
計次時靈時不靈MPU 鬆動 / 束帶太鬆滑動補膠鎖死;束帶拉緊到一指寬
休息倒數數字卡住cue() 的 delay 卡住主迴圈正常現象(最長 300ms);要更順把 delay 改 millis 排程
OLED 全黑地址 0x3C/0x3D 買錯版 / SDA-SCL 接反I2C scanner 掃一次;SuperMini 腳位圖先核對

驗證結果

專案檔案

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
← 上一篇:ESP32-H2 氣密窗保全感測器 📚 系列索引 下一篇:敬請期待 →