📋 專案概述
上個月騎車下班,內湖堤頂大道要右轉進社区,我伸右手示意——然後差點被後來的機車擦過。問題不是我不會打手勢,而是騎車打手勢等於單手握車 + 放棄煞車:右手離開把手的 3 秒裡,我既不能煞車也不能變速。更扯的是夜間,後車根本看不到你戴手套的手在幹嘛。
查過市面上的自行車轉向燈:
- 把手夾式轉向燈(賣場 399 元):要另外充電、另外一個開關、另外一個體積;按了之後閃在車把中間,後車看得到但不知道你要轉多急。
- 車牌式轉向燈組(1,500~3,000 元):要架後货架、接車架、體積大,對通勤車來說是殺雞用牛刀。
- 手勢感測器(高階款):靠手腕陀螺儀判斷轉向意圖——聽起來很酷,實際上是「每次轉車把都誤觸」的災難現場。
關鍵洞察:轉向意圖最可靠的來源不是陀螺儀,是騎士的大拇指。按鍵在拇指下、燈在車尾,中間隔著一根訊號線——這正是 ESP32 最擅長的活。這個專案把整套系統塞進原本就該存在的把手套殼裡,總成本 不到 300 元:
- 拇指按鍵操作:左鍵左轉流水、右鍵右轉流水,不離開握把
- 3 秒無操作自動熄滅:轉完彎不用低頭關燈(這功能做過的人都知道多重要)
- MPU6050 傾角輔助:轉彎角度越大,流水燈循環越快——後車能「感覺」你要轉多急
- 煞車紅燈:減速度 < -0.6g 自動亮紅 1.5 秒,後車秒懂
- 低頭求助閃燈:車頭下傾 > 35° 持續 1 秒(低頭看車尾的動作)→ 雙燈同閃,夜間被跟車時的低調求救
- IP54 防水 + 一週一充:下雨照騎,1000mAh 電池撐一週通勤
成本效益:賣場夾式燈 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 顆 ×2 | 2 | ~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 ×1 | 1 組 | ~2 元 | 電池分壓 → GPIO2(C3 唯一 ADC 腳群) |
| 其他 | 矽膠線 28AWG、熱縮管、M2×8 螺絲 ×4 | 1 組 | ~10 元 | 震動環境一律焊死,不用杜邦 |
| 合計 | ~292 元 |
🔌 接線圖
詳細接線
| ESP32-C3 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO4 / GPIO5 | 左 / 右燈條 DIN | 各 8 顆 WS2812B;訊號線串 330Ω 保護第一顆燈珠 |
| GPIO6 / GPIO7 | 左 / 右按鍵 → GND | 內部上拉,按下 = LOW;軟體 30ms 去彈跳 |
| GPIO8 / GPIO9 | MPU6050 SDA / SCL | I2C 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 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 把手殼主體(含握把段) | 1 | TPU 95A | 黑 | ~2.5 hr |
| 電子艙上蓋(螺絲式) | 1 | PETG | 黑 | ~40 min |
| 燈條導光蓋 | 2 | PETG(透明/白) | 透 | ~25 min |
| 按鍵帽 ×2 + 撥桿帽 | 3 | TPU 95A | 橘 | ~15 min |
列印設定
- 材質:主體 TPU——把手要抗震、抗摔、曬太陽不脆化;電子艙用 PETG 保持剛性
- 填滿:主體 25% 直線(抗震需求);上蓋 20%
- 層厚:0.16mm(TPU 慢速 20mm/s,表面質量換手感)
- 防水:層線方向避開握把段頂面;螺絲孔預留 M2 銅鑲件孔位(熱壓銅螺母防水又耐拆)
設計要點
- IP54 結構防水:上蓋止口 0.8mm 深、貼一圈 3M 泡棉膠條;按鍵帽與殼之間留 0.4mm 排水縫 + 底部兩個 Ø2mm 排水孔——防雨不防浸(騎車永遠不會泡水,但一定淋雨)。
- 22.2mm 車把卡箍:標準直把口徑,卡箍內貼 0.5mm 橡膠墊片,鎖緊扭矩 3Nm 不滑轉也不壓裂 TPU。
- 握把段即電池艙:803048 電池躺在右手握把正下方,重心貼手、拆換只開兩顆螺絲。
- 燈條導光槽:燈條壓入槽內再蓋導光蓋,出光面與把手外緣齊平——側向 45° 也能看到流水,這才是自行車燈該有的發光角度。
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📦 相依:Arduino core for ESP32 ≥ 3.0(開發板選「ESP32C3 Dev Module」)、Adafruit NeoPixel ≥ 1.12、Adafruit MPU6050。
📦 相依: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 組裝說明書
- 列印零件:TPU 主體、PETG 上蓋、導光蓋×2、按鍵帽×3
- 焊燈條:各 8 顆剪下、訊號線串 330Ω、端頭熱縮封死
- 焊電源鏈:電池 → TP4056 保護板 → 電源開關 → 主板 5V;分壓電阻焊 GPIO2
- 焊主板:燈條/按鍵/MPU 依接線表上板,全部焊死 + 熱縮管
- 預組測試:合蓋前燒錄跑自檢,左右流水方向確認無反
- 入殼:燈條入導光槽、電池入握把段、上蓋鎖 M2(銅鑲件)
- 上車調正:卡箍鎖 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 再燒 |
🎯 進階延伸
- 雷達煞車燈:加 HLK-LD2410(35 元),後方 5m 有車接近時煞車紅燈加亮——本專案最推薦的升級
- 動感尾燈 v2:C3 內建 BLE,連手機 App 記錄騎乘軌跡 + 煞車次數統計
- 太陽能補電:把 TP4056 前端並一片 1W 太陽能板,通勤族停放地下場外的白天就順手補電
- 雙車隊友同步:ESP-NOW(C3 支援)串另一台車,隊友煞車你的燈也閃——夜團神器
📦 檔案清單
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 # 說明文件