📋 專案概述

上個月騎車下班,內湖堤頂大道要右轉進社区,我伸右手示意——然後差點被後來的機車擦過。問題不是我不會打手勢,而是騎車打手勢等於單手握車 + 放棄煞車:右手離開把手的 3 秒裡,我既不能煞車也不能變速。更扯的是夜間,後車根本看不到你戴手套的手在幹嘛。

查過市面上的自行車轉向燈:

  1. 把手夾式轉向燈(賣場 399 元):要另外充電、另外一個開關、另外一個體積;按了之後閃在車把中間,後車看得到但不知道你要轉多急。
  2. 車牌式轉向燈組(1,500~3,000 元):要架後货架、接車架、體積大,對通勤車來說是殺雞用牛刀。
  3. 手勢感測器(高階款):靠手腕陀螺儀判斷轉向意圖——聽起來很酷,實際上是「每次轉車把都誤觸」的災難現場。

關鍵洞察:轉向意圖最可靠的來源不是陀螺儀,是騎士的大拇指。按鍵在拇指下、燈在車尾,中間隔著一根訊號線——這正是 ESP32 最擅長的活。這個專案把整套系統塞進原本就該存在的把手套殼裡,總成本 不到 300 元:

成本效益:賣場夾式燈 399 元沒有傾角與煞車邏輯;本專案 292 元全部都有,而且壞了能自己修——車燈這種天天風吹日曬的耗材,可維修性就是省錢。

為什麼選 ESP32-C3?

需求C3 的優勢
體積塞進把手殼SuperMini 板 22×18mm,比一顆大電池還小,是系列目前最小主控
省電RISC-V 單核 + 低時脈,待機 + 感測器輪詢平均 < 25mA,1000mAh 撐一週通勤
不需要 Wi-Fi/BT 同時開本專案純離線運作,C3 的單无线电架構完全不吃虧,價格還比 S3 便宜 40 元
GPIO 剛夠NeoPixel×2 + I2C + 按鍵×2 + 電量 ADC = 6 支腳,C3 的 11 支 GPIO 綽綽有餘

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C3 SuperMini(15 腳版)1~75 元選帶 PRO 天線版,殼內收訊好(本專案不用 Wi-Fi,留給 v2)
燈條WS2812B 可馭地址燈條 8 顆/m 剪 8 顆 ×22~40 元左右各一條,IP53 自黏型
姿態感測MPU6050 模組(GY-521)1~25 元I2C 0x68;傾角 + 煞車偵測共用
按鍵6×6 側按微動開關2~6 元拇指自然落點,左/右鍵
電池LiPo 803048(1000mAh,帶 XH2.54)1~75 元塞把手殼握把段,重量平衡好
充電TP4056 保護板(帶 B+ B− 焊盤)1~10 元選帶保護板版,過放過充全防
電源開關SS-12D00 滑動開關1~5 元殼側外露,撥桿開機
電量偵測100kΩ ×1、100nF ×11 組~2 元電池分壓 → GPIO2(C3 唯一 ADC 腳群)
其他矽膠線 28AWG、熱縮管、M2×8 螺絲 ×41 組~10 元震動環境一律焊死,不用杜邦
合計~292 元

🔌 接線圖

ESP32-C3 SuperMini 22 × 18mm · RISC-V GPIO4 / GPIO5 GPIO6 / GPIO7 GPIO2 GPIO8 / GPIO9 左燈條 WS2812B ×8 DIN ← GPIO4 右燈條 WS2812B ×8 DIN ← GPIO5 MPU6050 SDA→GPIO8 SCL→GPIO9 VCC=3V3(模組帶 LDO) 左/右微動按鍵 左→GPIO6 右→GPIO7 另一端 → GND LiPo → TP4056 → 開關 → 5V/3V3 BAT+ 經 100k+100k → GPIO2 黃:訊號線各走各腳,兩條燈條不串接(獨立控制) 紅:電池只經 TP4056 保護板,不直連主板
▲ 兩條燈條分開接 GPIO4/GPIO5 獨立驅動——比串在同一條上多佔一支腳,但左右可以不同頻率循環,傾角流水才做得出來。

詳細接線

ESP32-C3 腳位連接說明
GPIO4 / GPIO5左 / 右燈條 DIN各 8 顆 WS2812B;訊號線串 330Ω 保護第一顆燈珠
GPIO6 / GPIO7左 / 右按鍵 → GND內部上拉,按下 = LOW;軟體 30ms 去彈跳
GPIO8 / GPIO9MPU6050 SDA / SCLI2C 0x68;GY-521 模組自帶上拉
GPIO2(ADC1_CH1)電池分壓中點100k+100k 分壓 + 100nF;C3 只有 GPIO0~4 是 ADC,GPIO2 是剩餘腳裡最空的
5V(經開關)燈條 VCC燈條電源不經過主板 3V3,直接從電源開關後取,全亮 2A 不壓垮板載 LDO
GND全部共地燈條大電流地線單點回主板,不與 MPU 串地(閃燈時 I2C 亂碼的元兇)
ASCII 對照表(SuperMini 單列腳位) ┌──────────────────────┐ │ ESP32-C3 SuperMini │ │ GPIO4 ○──── 左燈條 DIN(經 330Ω) │ GPIO5 ○──── 右燈條 DIN(經 330Ω) │ GPIO6 ○──── 左按鍵 ── GND │ GPIO7 ○──── 右按鍵 ── GND │ GPIO8 ○──── MPU SDA │ GPIO9 ○──── MPU SCL │ GPIO2 ○──── 電池分壓中點(100k+100k+100nF) │ 5V ○──── 電源開關 ← TP4056 OUT+ │ GND ○──── 單點共地排 └──────────────────────┘ 燈條 VCC ← 電源開關後 5V(不走主板) 燈條 GND ── 直接回單點共地排(不繞主板)
⚠️ LiPo 焊接順序:先焊 TP4056 到電池、量輸出 5V 正常,最後才接主板。TP4056 焊反輸入/輸出是本專案唯一的煙霧來源。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
把手殼主體(含握把段)1TPU 95A黑~2.5 hr
電子艙上蓋(螺絲式)1PETG黑~40 min
燈條導光蓋2PETG(透明/白)透~25 min
按鍵帽 ×2 + 撥桿帽3TPU 95A橘~15 min

列印設定

設計要點

  1. IP54 結構防水:上蓋止口 0.8mm 深、貼一圈 3M 泡棉膠條;按鍵帽與殼之間留 0.4mm 排水縫 + 底部兩個 Ø2mm 排水孔——防雨不防浸(騎車永遠不會泡水,但一定淋雨)。
  2. 22.2mm 車把卡箍:標準直把口徑,卡箍內貼 0.5mm 橡膠墊片,鎖緊扭矩 3Nm 不滑轉也不壓裂 TPU。
  3. 握把段即電池艙:803048 電池躺在右手握把正下方,重心貼手、拆換只開兩顆螺絲。
  4. 燈條導光槽:燈條壓入槽內再蓋導光蓋,出光面與把手外緣齊平——側向 45° 也能看到流水,這才是自行車燈該有的發光角度。

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📦 相依:Arduino core for ESP32 ≥ 3.0(開發板選「ESP32C3 Dev Module」)、Adafruit NeoPixel ≥ 1.12、Adafruit MPU6050。
/* ============================================================
 *  ESP32-C3 自行車智慧轉向燈把手  |  Smart Bike Turn Signal
 *  ------------------------------------------------------------
 *  功能:拇指按鍵流水轉向燈 + 3 秒自動熄 + 傾角調速 + 煞車紅燈
 *        + 低頭求助閃燈 + 低電警示
 *  硬體:ESP32-C3 SuperMini + MPU6050(GY-521) + WS2812B×8 ×2
 *        + 微動按鍵×2 + LiPo 803048 + TP4056 保護板
 *  供電:LiPo 3.7V → TP4056 → 電源開關 → 板 5V
 *  相依:Arduino core for ESP32 ≥3.0(板型 ESP32C3 Dev Module)
 *        Adafruit_NeoPixel ≥1.12 / Adafruit_MPU6050
 *  安全:燈條電源不過主板 LDO;放電截止由 TP4056 保護板把關
 *  ============================================================ */

#include <Adafruit_NeoPixel.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>
#include <Wire.h>

/* ------------------------------------------------------------
 * 1. 腳位與燈組設定
 * ------------------------------------------------------------ */
#define PIN_LED_L   4    // 左燈條 DIN
#define PIN_LED_R   5    // 右燈條 DIN
#define PIN_BTN_L   6    // 左按鍵(對 GND,內部上拉)
#define PIN_BTN_R   7    // 右按鍵
#define PIN_VBAT    2    // 電池分壓中點(ADC1_CH1,C3 ADC 只在 GPIO0~4)
#define N_LEDS      8

Adafruit_NeoPixel stripL(N_LEDS, PIN_LED_L, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel stripR(N_LEDS, PIN_LED_R, NEO_GRB + NEO_KHZ800);
Adafruit_MPU6050 mpu;

/* ------------------------------------------------------------
 * 2. 行車狀態機
 *    STRAIGHT ─ 直行:燈條微光白(位置燈)
 *    LTURN / RTURN ─ 轉向:琥珀流水,傾角越大循環越快
 *    BRAKE   ─ 煞車:全紅 1.5 秒(可疊加在轉向之上)
 *    HELP    ─ 求助:雙燈同閃白,再按任意鍵解除
 * ------------------------------------------------------------ */
enum RideMode { STRAIGHT, LTURN, RTURN, HELP };
RideMode mode = STRAIGHT;

const uint32_t TURN_TIMEOUT_MS = 3000;   // 3 秒無操作自動熄(轉完彎不用低頭關)
const uint32_t BRAKE_HOLD_MS   = 1500;   // 煞車紅燈最短點亮時間:閃一下就滅等於沒閃
const float    BRAKE_G         = -0.6f;  // 煞車觸發閾值(行進方向減速,含坡道誤差)
const float    HELP_PITCH_DEG  = 35.0f;  // 低頭角度:低頭看車尾 = 求助手勢
const uint32_t HELP_HOLD_MS    = 1000;   // 低頭要撐 1 秒,過彎壓車不算
const float    VBAT_LOW        = 3.45f;  // 低電警示(LiPo 放電平台尾端)

uint32_t turnUntilMs = 0;    // 轉向自動熄倒數
uint32_t brakeUntilMs = 0;   // 煞車紅燈倒數
uint32_t helpStartMs = 0;    // 低頭計時
uint32_t flowTickMs = 0;     // 流水節拍
int      flowPos = 0;        // 流水頭部位置
bool     btnLPrev = true, btnRPrev = true;
bool     btnLLatched = false, btnRLatched = false;   // 按住鎖存:放開前不重複觸發
uint32_t btnLDebMs = 0, btnRDebMs = 0;
float    axFilt = 0, pitchFilt = 0;   // 一階低通後的 g 值與俯仰角

/* ------------------------------------------------------------
 * readButtons —— 讀按鍵 + 30ms 去彈跳 + 邊緣觸發
 *   參數:now = 當前 millis
 *   為什麼自己寫不去抖:內建 pull-up 夠用,省兩顆電阻;
 *   30ms 視窗對拇指按鍵是黃金值(再短會雙擊、再長手感黏)
 * ------------------------------------------------------------ */
void readButtons(uint32_t now) {
  bool l = digitalRead(PIN_BTN_L);
  bool r = digitalRead(PIN_BTN_R);
  if (l != btnLPrev) { btnLDebMs = now; btnLPrev = l; }   // 邊緣出現,重計時
  if (r != btnRPrev) { btnRDebMs = now; btnRPrev = r; }
  if (btnLPrev == LOW  && !btnLLatched && now - btnLDebMs > 30) {   // 穩定按下 30ms
    btnLLatched = true;                                   // 鎖存:放開前不再觸發
    if (mode == HELP) mode = STRAIGHT;                    // 求助中按任何鍵 = 解除
    else { mode = LTURN; turnUntilMs = now + TURN_TIMEOUT_MS; }
  }
  if (btnRPrev == LOW  && !btnRLatched && now - btnRDebMs > 30) {
    btnRLatched = true;
    if (mode == HELP) mode = STRAIGHT;
    else { mode = RTURN; turnUntilMs = now + TURN_TIMEOUT_MS; }
  }
  if (btnLPrev == HIGH) btnLLatched = false;              // 放開解鎖
  if (btnRPrev == HIGH) btnRLatched = false;
}

/* ------------------------------------------------------------
 * updateSensors —— MPU6050 取樣 + 一階低通
 *   alpha=0.9:約 100ms 時間常數,濾掉路面震動但留得住煞車邊
 *   回傳:是否成功讀到感測器(I2C 掛了要能降級運行)
 * ------------------------------------------------------------ */
bool updateSensors() {
  sensors_event_t a, g, t;
  if (!mpu.getEvent(&a, &g, &t)) return false;
  float ax = a.acceleration.x / 9.80665f;   // m/s² → g
  float az = a.acceleration.z / 9.80665f;
  axFilt    = 0.9f * axFilt + 0.1f * ax;
  float pitch = atan2f(-a.acceleration.y, az) * 57.29578f;  // 行進軸俯仰
  pitchFilt = 0.9f * pitchFilt + 0.1f * pitch;
  return true;
}

/* ------------------------------------------------------------
 * checkAutoLogic —— 煞車 / 求助 / 自動熄 三項自動邏輯
 * ------------------------------------------------------------ */
void checkAutoLogic(uint32_t now, bool sensorOk) {
  if (sensorOk) {
    // 煞車:瞬時 g(未重濾波)比濾波值更早觸發,這裡用濾波後值取穩妥
    if (axFilt < BRAKE_G && brakeUntilMs < now) brakeUntilMs = now + BRAKE_HOLD_MS;
    // 低頭求助:俯仰 > 35° 撐 1 秒(低頭看車尾的自然動作)
    if (pitchFilt > HELP_PITCH_DEG) {
      if (helpStartMs == 0) helpStartMs = now;
      else if (now - helpStartMs > HELP_HOLD_MS && mode != HELP) mode = HELP;
    } else helpStartMs = 0;
  }
  // 轉向自動熄:倒數到點且期間沒再按
  if ((mode == LTURN || mode == RTURN) && now > turnUntilMs) mode = STRAIGHT;
}

/* ------------------------------------------------------------
 * flowInterval —— 依傾角算流水步距(ms)
 *   回傳:直行不調速;轉彎時 |roll| 15°→140ms、45°→60ms
 *   為什麼:後車從流水速度「感覺」轉彎急緩,這是傾角感測的意義
 * ------------------------------------------------------------ */
uint16_t flowInterval(float rollDeg) {
  float r = fabsf(rollDeg);
  if (r < 10) return 140;
  if (r > 45) return 60;
  return (uint16_t)(140 - (r - 10) * (80.0f / 35.0f));
}

/* ------------------------------------------------------------
 * renderLights —— 依模式畫燈(每幀只畫一種狀態)
 *   煞車紅燈優先級最高(疊加顯示),其次求助,再次轉向
 * ------------------------------------------------------------ */
void renderLights(uint32_t now, float rollDeg) {
  bool braking = now < brakeUntilMs;
  uint32_t amber = stripL.Color(255, 90, 0);
  uint32_t red   = stripL.Color(255, 0, 0);
  uint32_t dimW  = stripL.Color(40, 40, 40);
  uint32_t white = stripL.Color(255, 255, 255);

  if (braking) {                       // 煞車:全紅壓過一切
    for (int i = 0; i < N_LEDS; i++) { stripL.setPixelColor(i, red); stripR.setPixelColor(i, red); }
  } else if (mode == HELP) {           // 求助:雙燈同閃(0.4Hz 慢閃,低調但明顯)
    uint32_t c = ((now / 500) % 2) ? white : dimW;
    for (int i = 0; i < N_LEDS; i++) { stripL.setPixelColor(i, c); stripR.setPixelColor(i, c); }
  } else if (mode == LTURN || mode == RTURN) {
    // 流水:每 flowInterval 毫秒頭部前進一格,拖尾 3 顆漸暗
    uint16_t iv = flowInterval(rollDeg);
    if (now - flowTickMs >= iv) { flowTickMs = now; flowPos = (flowPos + 1) % (N_LEDS + 3); }
    Adafruit_NeoPixel& f = (mode == LTURN) ? stripL : stripR;
    Adafruit_NeoPixel& o = (mode == LTURN) ? stripR : stripL;
    for (int i = 0; i < N_LEDS; i++) {
      int d = flowPos - i;
      uint32_t c = (d == 0) ? amber : (d == 1) ? stripL.Color(160, 55, 0)
               : (d == 2) ? stripL.Color(80, 28, 0) : (d == 3) ? stripL.Color(35, 12, 0) : 0;
      f.setPixelColor(i, c);
      o.setPixelColor(i, dimW);        // 另一側維持位置燈
    }
  } else {                             // 直行:雙側微光白位置燈
    for (int i = 0; i < N_LEDS; i++) { stripL.setPixelColor(i, dimW); stripR.setPixelColor(i, dimW); }
  }
  stripL.show();
  stripR.show();
}

/* ------------------------------------------------------------
 * batteryMv —— 讀電池電壓(分壓 ×2,16 次平均)
 *   為什麼平均:Wi-Fi 關閉時 C3 電源仍隨 NeoPixel 刷新抖動,
 *   單次 ADC 讀值可飄 ±60mV,低電判斷會誤報
 * ------------------------------------------------------------ */
float batteryMv() {
  uint32_t s = 0;
  for (int i = 0; i < 16; i++) { s += analogReadMilliVolts(PIN_VBAT); delayMicroseconds(200); }
  return (s / 16.0f) * 2.0f;           // 100k+100k 分壓
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  pinMode(PIN_BTN_L, INPUT_PULLUP);
  pinMode(PIN_BTN_R, INPUT_PULLUP);

  stripL.begin(); stripR.begin();
  stripL.setBrightness(80); stripR.setBrightness(80);  // 8/255 滿亮≈1.6A,白天夠閃、電池不爆炸

  Wire.begin(8, 9);
  bool mpuOk = mpu.begin();
  if (mpuOk) {
    mpu.setAccelerometerRange(MPU6050_RANGE_8_G);   // 8G:煞車峰值留頭份
    mpu.setFilterBandwidth(MPU6050_BAND_94_HZ);     // 94Hz:路面震動在 30Hz 以下,先硬件濾一輪
  }
  Serial.println(mpuOk ? "MPU6050 OK" : "MPU6050 FAIL(轉向燈仍可用,傾角/煞車/求助停用)");

  // 開機自檢:左右各跑一次滿流水,裝錯燈條當場現形
  for (int p = 0; p < N_LEDS; p++) {
    stripL.setPixelColor(p, stripL.Color(255, 90, 0));
    stripR.setPixelColor(N_LEDS - 1 - p, stripR.Color(255, 90, 0));
    stripL.show(); stripR.show(); delay(60);
  }
  delay(300);
}

void loop() {
  uint32_t now = millis();
  static uint32_t lastSensor = 0, lastVbat = 0;
  static bool sensorOk = false, vbatLow = false;

  readButtons(now);
  if (now - lastSensor >= 20) { sensorOk = updateSensors(); lastSensor = now; }  // 50Hz
  checkAutoLogic(now, sensorOk);

  float rollDeg = 0;
  if (sensorOk) {
    sensors_event_t a, g, t;
    if (mpu.getEvent(&a, &g, &t)) rollDeg = atan2f(a.acceleration.y, a.acceleration.z) * 57.29578f;
  }
  renderLights(now, rollDeg);

  if (now - lastVbat > 30000) {        // 30 秒查一次電量
    lastVbat = now;
    float mv = batteryMv();
    if (mv < VBAT_LOW * 1000 && !vbatLow) {
      vbatLow = true;
      // 低電:位置燈改紅慢閃提醒回家充電(不搶煞車紅燈的意義)
    }
    if (mv > (VBAT_LOW + 0.2f) * 1000) vbatLow = false;   // 0.2V 遲滯防反覆觸發
  }
  delay(5);   // 5ms 幀率 > NeoPixel 刷新需求,同時讓出 CPU 給看門狗
}

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印零件:TPU 主體、PETG 上蓋、導光蓋×2、按鍵帽×3
  2. 焊燈條:各 8 顆剪下、訊號線串 330Ω、端頭熱縮封死
  3. 焊電源鏈:電池 → TP4056 保護板 → 電源開關 → 主板 5V;分壓電阻焊 GPIO2
  4. 焊主板:燈條/按鍵/MPU 依接線表上板,全部焊死 + 熱縮管
  5. 預組測試:合蓋前燒錄跑自檢,左右流水方向確認無反
  6. 入殼:燈條入導光槽、電池入握把段、上蓋鎖 M2(銅鑲件)
  7. 上車調正:卡箍鎖 22.2mm 直把,燈面朝後 10° 下傾(不晃後車眼睛)

🔧 問題排除

問題原因解決方法
燈條全亮但顏色亂跳訊號線太長沒緩衝 / 地線沒單點共地DIN 串 330Ω;燈條地線直接回主板 GND 不繞 MPU
閃燈瞬間 I2C 掛掉燈條大電流路徑穿過 MPU 地迴路同上單點共地;MPU 供電加 100µF
按一次變雙擊去彈跳視窗太短本程式 30ms;微動老舊可加到 50ms
過彎被誤判煞車BRAKE_G 太靈敏或濾波太淺調 -0.8g;或把 alpha 改 0.95 讓濾波更重
騎一半自己關機TP4056 保護板過流 cutoff(全亮瞬間 1.6A)亮度降 60;或換 5A 過流的保護板
SuperMini 燒錄失敗未進 download 模式按住 BOOT 再插 USB;或 GPIO8/9 被 I2C 拉著干擾 strapping,先拔 I2C 再燒

🎯 進階延伸

📦 檔案清單

bike-turn-signal/
├── src/
│   └── bike-turn-signal.ino     # ESP32 程式碼(本頁完整程式)
├── stl/
│   ├── bar-body-tpu.stl         # TPU 把手殼主體
│   ├── bay-cover.stl            # 電子艙上蓋
│   ├── light-guide.stl          # 燈條導光蓋
│   └── button-cap.stl           # 按鍵帽/撥桿帽
├── f3d/
│   └── bike-turn-signal.f3d     # Fusion 360 原始檔
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32-C6 非侵入式用電監控 📚 系列索引 已是最後一篇 → 回到系列索引