📋 專案概述
你爸爸 68 歲,白內障術後兩個月,視力表能看清 0.6,但那是坐著、順光、專心的成績。真正出事的是黃昏的社區公園:逆光那陣子,他的世界對比度塌了一半——機車把手、花圃邊緣、沒蓋好的水溝蓋,這些「膝蓋到腰之間」的東西出現在他腳前的時間,比他認出它的時間晚了半秒。這半秒你沒見過,但上個月他手腕瘀青那次你見過了。你買過一支 NTT 三千的「電子導盲杖」試水溫:整支杖內嵌雷達,聽起來很未來,實際上重 780g、充電口在杖尾(每次充電要整支倒插)、警示是「嗶嗶嗶」三連音——在公園裡被圍棋阿公們注目,他用兩次就收進陽臺。問題從頭到尾不是「他需要機器人」,是他手上那支用了八年、長度調好、握把有手掌記憶的登山杖,要能早半秒知道前方有東西——而且不能用聽的,公園環境音太吵,聽覺在老人身上本來就是劣勢頻道,要用握把上的震動說話。
市售商品:
- 整支電子導盲杖:NT$3,000–9,000,重量懲罰(每天多舉 400g 八千步)、不可移植到你調好的杖、警報多為單段嗶聲——「有東西」和「兩公尺外有個看板」用同一個聲音,聽三週就學會忽略。
- 手機 LiDAR 避障 App:免費試用很多,但要求他單手舉手機探路——手的數量和平衡的預算,剛好都不夠。
- 導盲犬:真正的黃金標準,缺點是配額像熱門演唱會門票,而且你爸跟牠相處的時間會比跟你還長——這點我們服。
本專案功能清單:
- 分段震動,不靠耳朵:前方 150–90cm 慢脈衝(「注意」)、90–45cm 快脈衝(「減速」)、<45cm 連續震動+蜂鳴器(「停」)——震動頻率本身就是資訊,距離感用手的皮膚讀
- 實戰濾波:每 100ms 量一次、五個取中值、雙脈波防殘影、30ms 逾時判「無回波=遠於 5m」——斜面前方草地不會讓它亂叫
- 遲滯 5cm 防跳變:站在 90cm 邊界不會「慢快慢快」抽風
- 忘關自動深睡:連續 120 秒沒障礙就進深度睡眠(C3 晶片待機 5µA),按鈕一按即醒——真正的開關他記得用,這個功能是接住「忘記」的
- 電力自知:開機雙震報電量(充足雙短震/低電長震),低於 3.35V 紅燈慢閃,不會在半山腰無預警罷工
- 全重 58g、NT$380:束帶鎖在你自己的杖上,8 小時健行一條 LiPo 剩餘量還看得見
💰 成本效益:NT$380 對比整支電子杖的 NT$3,000 起跳,而且重量只有它的十分之一。更重要的是「可移植」:這套模組可以今天上登山杖、明早上他散步的長傘——他不需要適應新杖,杖不需要適應新主人。
為什麼選 ESP32-C3?
| 需求 | C3 的優勢 |
|---|---|
| 手上東西要輕、電要省 | SuperMini 板 3.5g;C3 深度睡眠晶片層級 5µA,且本機從不開射電——Wi-Fi/藍牙根本不啟動,待機預算全留給感測與震動 |
| 100ms 週期的「準即時」 | 單核 RISC-V @160MHz 處理脈寬量測+濾波+非阻塞震動狀態機,負載連零頭都不到 |
| 現場調參要方便 | C3 原生 USB:免外接晶片,USB-C 線一插就能改門檻值重燒——在山上的帳篷裡也能調 |
| 成本與取得 | NT$110 隨處買、壞了補貨三分鐘;這機器註定要被長輩的手、雨和石頭測試忠誠度 |
⚠️ 這不是醫療器材,是「多一層提醒」的輔助玩具:超聲波錐角約 15 度、對軟物(灌木、衣服)回波弱、看不到地面落差。它降低的是碰撞概率,不能替代白杖與常識——文章末尾的測試清單請真的跑完再給家人用。
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C3 SuperMini | 1 | ~110 元 | 原生 USB-C 燒錄與充電共用一條線 |
| 測距 | HC-SR04-33(3.3V 版) | 1 | ~35 元 | 量測 2–400cm、精度 ±3mm、角錐 15 度、工作電流 15mA;買 3.3V 版省掉 Echo 分壓 |
| 震動 | 1027 扁平 coin 馬達+熱縮套 | 1 | ~20 元 | 貼在握把握環內側;1.5V 小馬達別直接 3.3V 長轉 |
| 蜂鳴器 | 被動式 85mm+S8050×2+1kΩ×2 | 1 組 | ~20 元 | 只有「停」區段登場,多數時間沉默 |
| 電池 | LiPo 3.7V 600mAh(502530)+XH2.54 線 | 1 | ~60 元 | 30g 級;扁形好塞杖夾背艙 |
| 充電 | TP4056 模組(帶保護,B 型) | 1 | ~15 元 | 充飽自動斷;紅藍燈從殼側窗可見 |
| 開關 | MSK-12C02 滑動開關 | 1 | ~5 元 | 機械斷電主開關——給長輩的東西要能「真的關掉」 |
| 按鈕 | 6×6 防水輕觸開關 | 1 | ~5 元 | 喚醒/測試;深睡期間待命 |
| 電阻 | 100kΩ×2(電壓分壓)、10kΩ(按鈕下拉) | — | ~5 元 | 若買 5V 版 HC-SR04 另備 10k/20k 分壓 Echo |
| 二極體 | 1N4148(馬達續流) | 1 | ~2 元 | 跨馬達兩腳;沒它三極體會死給你看 |
| 列印材料 | PETG 約 22g | — | ~22 元 | outdoors 紫外線與雨水,PETG 比 PLA 撐得久 |
| 合計 | ~300 元 | 加備用電池與魔鬼氈束帶約 NT$380 |
🔌 接線圖
ESP32-C3 SuperMini 對照表:
C3 腳位 連到 線色 備註
──────────────────────────────────────────────────────────────
VIN 滑動開關→電池正極 紅 機械主開關切在這裡
3V3 HC-SR04 VCC、蜂鳴器 橘 LDO 輸出,約 3.3V/400mA 內
GPIO6 HC-SR04 Trig 黃 每 100ms 一發 10µs
GPIO7 HC-SR04 Echo 綠 3.3V 版直連;5V 版要 10k/20k 分壓
GPIO2 馬達 S8050 基極←1k 紫 續流二極體別忘
GPIO3 蜂鳴器 S8050 基極←1k 白 僅「停」區段使用
GPIO1 按鈕→3V3+10k 下拉 藍 EXT1 高準位喚醒
GPIO0 100k+100k 分壓中點 棕 電池電壓監視
GND 全部共地 黑 —
📳 回饋分級設計
🧊 3D 列印設計
| 列印件 | 尺寸/數量 | 材料與設定 | 時間 |
|---|---|---|---|
| 杖夾(口徑 Ø20–26mm 彈性卡榫+M3 螺絲鎖環) | 1 | PETG、0.2mm、壁 4 層(受力件)、花紋 40% | 55m |
| 主機殼(超聲波雙窗 16mm、側窗留 TP4056 充電指示燈與 USB-C 孔) | 1 | PETG、0.2mm、窗框薄壁 0.8mm 留感測靈敏度 | 1h15m |
| 防水蓋(感測窗壓蓋,矽膠圈槽) | 1 | PETG+矽膠條 1.5mm(或熱熔膠替代) | 18m |
| 握把震動墊座(馬達貼環) | 1 | TPU 95A、填充 100%(震得實) | 12m |
🌧️ 防水哲學:整機不追求浸水等級,追「大雨 20 分鐘」——感測窗開孔直徑壓到 16mm 加導雨斜面,殼體接縫打矽利康;淋暴雨後擦乾窗膜再上路(窗膜水珠會讓它以為世界 4cm)。材料選 PETG 是因為山裡紫外線讓 PLA 兩年變餅乾。
💻 韌體(完整程式)
Arduino IDE 選 ESP32C3 Dev Module、core 3.x;本機零外部依賴。
/* ============================================================
* cane_radar.ino — 智慧登山杖障礙雷達(ESP32-C3 SuperMini)
* 功能:HC-SR04 每 100ms 測距 → 中值濾波 → 距離分三區,
* 握把馬達以脈衝節奏震動回饋;<45cm 加蜂鳴器;
* 120 秒無障礙自動深度睡眠,按鈕喚醒。
* 硬體:Trig(6)/Echo(7)/馬達(2)/蜂鳴(3)/按鈕(1)/電池ADC(0)
* 供電:LiPo 3.7V → 滑動開關 → 板 VIN(經板載 LDO)
* 相依:arduino-esp32 core 3.x(板型 ESP32C3 Dev Module)
* ============================================================ */
#include <esp_sleep.h>
/* --- 1. 門檻與調參(先照抄,組裝步驟 7 再依現場校) --- */
const uint16_t Z_NEAR = 45; // cm:停——連續震+蜂鳴器
const uint16_t Z_MID = 90; // cm:減速——快脈衝
const uint16_t Z_FAR = 150; // cm:注意——慢脈衝
const uint16_t HYST = 5; // cm:區段切換遲滯,防邊界跳變
const uint32_t IDLE_MS = 120000; // 120s 無近物進深睡
#define PIN_TRIG 6
#define PIN_ECHO 7
#define PIN_MOTOR 2
#define PIN_BUZZ 3
#define PIN_BTN 1
#define PIN_VBAT 0
/* --- 2. 全域狀態 --- */
uint16_t buf[5]; // 滑動視窗:五個取中值
uint8_t bufN = 0, bufI = 0;
int8_t zone = 0; // 0 靜 1 注意 2 減速 3 停
uint32_t lastNearMs = 0; // 上次「看見近物」時間(idle 計時用)
uint32_t lastPulseAt = 0; // 非阻塞脈衝節拍器
bool pulseOn = false;
uint8_t pulseCnt = 0;
/* --- 3. 單次測距:雙脈波激發+逾時防呆 ---
* 說明:HC-SR04 資料手冊範圍 2–400cm、精度 ±3mm;聲速
* 340m/s → 距離 cm = 回波 µs / 58。逾時 30ms 代表
* 5m 內無有效回波(開闊山谷/吸音灌木),回 999。 */
uint16_t pingOnce() {
digitalWrite(PIN_TRIG, LOW); delayMicroseconds(4);
digitalWrite(PIN_TRIG, HIGH); delayMicroseconds(10);
digitalWrite(PIN_TRIG, LOW);
uint32_t us = pulseIn(PIN_ECHO, HIGH, 30000); // 逾時回 0,絕不死等
if (!us) return 9999; // 「很遠」也是有效答案
uint16_t cm = us / 58;
return (cm < 2) ? 9999 : cm; // 2cm 以下是自激雜訊
}
/* --- 4. 五個取中值濾波:單發飛點(鳥、風、鄰杖)進不了門 --- */
uint16_t medianPing() {
buf[bufI] = pingOnce();
bufI = (bufI + 1) % 5;
if (bufN < 5) { bufN++; if (bufN < 5) return buf[0]; } // 暖機期
uint16_t s[5];
for (uint8_t i = 0; i < 5; i++) s[i] = buf[i];
for (uint8_t i = 0; i < 4; i++) // 5 元素插入排序
for (uint8_t j = 0; j < 4 - i; j++)
if (s[j] > s[j + 1]) { uint16_t t = s[j]; s[j] = s[j + 1]; s[j + 1] = t; }
return s[2];
}
/* --- 5. 區段決策(含遲滯) ---
* 回傳:新 zone。規則:升區立刻生效(寧可多報),
* 降區要超出門檻 5cm 才生效(寧可少放)。 */
int8_t decide(uint16_t cm) {
int8_t want = zone;
uint16_t dn = HYST; // 降區要多退 5cm;升區即時生效
if (cm < Z_NEAR + (zone >= 3 ? dn : 0)) want = 3;
else if (cm < Z_MID + (zone < 3 ? dn : 0) && zone < 3) want = 2;
else if (cm < Z_FAR + (zone <= 2 ? dn : 0) && zone <= 2) want = 1;
else if (cm > Z_FAR) want = 0;
return want;
}
/* --- 6. 非阻塞震動引擎:setup 之外不 delay 測距迴圈 ---
* 區間震動樣式:zone1 每 1000ms 脈 80ms;zone2 每 450ms
* 脈 80ms;zone3 長通。蜂鳴器只在 zone3 每 600ms 短嗶。 */
void motorTick(uint16_t cm) {
uint32_t now = millis();
switch (zone) {
case 0: digitalWrite(PIN_MOTOR, LOW); noTone(PIN_BUZZ); break;
case 1: case 2: {
uint32_t period = (zone == 1) ? 1000UL : 450UL;
if (now - lastPulseAt >= period) { lastPulseAt = now; pulseOn = true; pulseCnt = now; }
digitalWrite(PIN_MOTOR, (pulseOn && now - pulseCnt < 80) ? HIGH : LOW);
if (pulseOn && now - pulseCnt >= 80) pulseOn = false;
noTone(PIN_BUZZ);
break;
}
case 3:
digitalWrite(PIN_MOTOR, HIGH);
if (now - lastPulseAt >= 600) { lastPulseAt = now; pulseOn = !pulseOn; }
if (pulseOn) tone(PIN_BUZZ, 2400); else noTone(PIN_BUZZ);
break;
}
if (cm < Z_FAR) lastNearMs = now; // 只有近物重置 idle 計時
}
/* --- 7. 開機電量自報:分壓 ADC ×2 換算 mV ---
* 回傳 mV;<3350 低電(長震+紅字序列埠),3350–3600 黃色區 */
uint32_t bootBattery() {
uint32_t mv = analogReadMilliVolts(PIN_VBAT) * 2;
if (mv == 0) return 0; // ADC 沒接也不炸
if (mv < 3350) { // 低電:一記長震優先警告
digitalWrite(PIN_MOTOR, HIGH); delay(600); digitalWrite(PIN_MOTOR, LOW);
} else { // 正常:雙短震報平安
for (int i = 0; i < 2; i++) {
digitalWrite(PIN_MOTOR, HIGH); delay(60);
digitalWrite(PIN_MOTOR, LOW); delay(140);
}
}
return mv;
}
/* --- 8. 深度睡眠:按鈕高準位喚醒(EXT1) --- */
void goSleep() {
digitalWrite(PIN_MOTOR, LOW); noTone(PIN_BUZZ);
esp_sleep_enable_ext1_wakeup(BIT64(PIN_BTN), ESP_EXT1_WAKEUP_ANY_HIGH);
esp_deep_sleep_start();
}
void setup() {
Serial.begin(115200);
pinMode(PIN_TRIG, OUTPUT); pinMode(PIN_ECHO, INPUT);
pinMode(PIN_MOTOR, OUTPUT); pinMode(PIN_BUZZ, OUTPUT);
pinMode(PIN_BTN, INPUT_PULLDOWN);
analogSetAttenuation(ADC_11db);
digitalWrite(PIN_MOTOR, LOW);
bootBattery(); // 雙短震=電量 OK
}
void loop() {
static uint32_t lastPing = 0;
if (millis() - lastPing < 100) { motorTick(0); return; } // 10Hz 量測節拍
lastPing = millis();
uint16_t cm = medianPing(); // 每 100ms 一發
if (digitalRead(PIN_BTN)) cm = 10; // 按住按鈕=強制測試模式(全區段試跑)
zone = decide(cm);
motorTick(cm);
if (millis() - lastNearMs > IDLE_MS && zone == 0) goSleep();
static uint32_t lastLog = 0; // 2Hz 序列表,調參時盯著它走
if (millis() - lastLog > 500) {
lastLog = millis();
Serial.printf("%4d cm z%d %lumV\n", cm, zone,
analogReadMilliVolts(PIN_VBAT) * 2);
}
}
⚠️ 代碼裡兩個最容易被「優化掉」的防呆:①
pingOnce() 的 30ms 逾時——拿掉它,Echo 卡高準位時 pulseIn 會無限死等,整支杖變成一根棍;②decide() 的遲滯——拿掉它,站在 90cm 邊界的草叢會讓你手腕抽經。🔧 組裝重點速覽
- 先用紙箱+雙面膠做「醜版」戴在杖上走一天,確認震動隔手套辨得出三級,再列印正式件。
- 感測窗朝向:雙管軸線保持水平朝正前方 5 度下傾——朝太多會全程看地,朝太少只看遠山。
- 量測校準:拿皮尺在 45/90/150cm 各貼地標站一次,序列埠讀數與實際差 >8cm 再調
Z_*常數。 - 120 秒深睡測試:開機靜置兩分鐘不動,馬達應安靜到可以被按醒;醒來雙短震。
- 下水測試:噴霧瓶對感測窗噴到掛水珠,觀察它是否「以為 4cm」——把「雨後擦窗」寫進使用說明。
📘 八步驟圖文版(含杖夾鎖付扭力、震動辨級測試、雨天演練與給長輩的使用簡卡)→ 登山杖雷達組裝說明書,附 A4 列印樣式。
🛠️ 問題排除
| 症狀 | 真正原因 | 處置 |
|---|---|---|
| 對空地一直顯示 4cm | 感測窗膜上水珠/殼內凝露 | 擦窗;列印件窗框內側加導水槽;山中溫差大,進帳篷先開蓋除露 |
| 震動時強時弱、偶發重啟 | 馬達啟動浪湧拉垮電源(LiPo 內阻+細線) | 馬達供電線加粗到 24AWG 以上、電池就近並 220µF;VIN 走線別跟訊號絞在一起 |
| Echo 讀值恆 9999 | 多半是 Trig/Echo 對調,或 Echo 走了分壓後接錯腳 | 換 3.3V 版(本文規格);5V 版 Echo 走 10k/20k 分壓後進 GPIO7 |
| 灌木叢前狂叫、斜草坡卻沉默 | 15 度角錐打軟物回波弱;斜坡把聲波彈開 | 把杖尖多往前送 20cm;仍不靈就把 Z_FAR 降到 120cm 減少誤判面 |
| 兩支杖同時用會互幹 | 同頻超聲波互聽成回波 | 韌體加隨機 0–60ms 激發抖動;或前後拉開 3m 以上 |
| 測試時動輒睡著打斷量測 | 兩分鐘無近物觸發 idle 屬設計行為,不是 bug | 校準期把 IDLE_MS 放大到 600000;正式使用改回 120000 |
| 序列埠電壓讀值亂跳 ±80mV | ADC 參考噪聲(未接濾波) | GPIO0 對地加 100nF;或改開機量一次+每 5 分鐘平均 |
| 深睡後待機電流量到 2mA | TP4056 還插著 USB(指示燈一直在)/開關位置不對 | 拔線再量;開關必須切在電池與 VIN 之間 |
| SuperMini 燒不進去 | Windows 驅動/埠佔用 | 按住 BOOT 點上傳、「Writing」瞬間放開;換一條「有數據芯」的 USB-C 線(很多線只通電) |
| 用了兩週開始誤報 | 杖夾鬆了,感測頭角度下栽看地 | M3 鎖環重新鎖 1.5N·m(手感=轉緊再 1/4 圈);卡榫內貼 0.5mm 矽膠墊 |
🚀 進階延伸
- TOF 升級:把 HC-SR04 換 VL53L0X(I2C,雷射測距 ±3%、錐角更小、不吃軟物),震動邏輯不改——成本 +NT$90,霧天與夜間表現明顯更好;雷射功率安全級 Class 1,別拆殼對眼
- 跌倒偵測:加 MPU6050(I2C 同總線),免費延遲+角速度閾值判跌倒 → 藍牙信標推 NAS → Line Notify 通知家人——震動回饋對長輩是輸出,這條補上「出事時的輸出」
- 語音版:DFPlayer Mini+3W 小喇叭,「前方有牆壁」粵語國語隨你錄——但請先確認使用者不覺得被機器人指揮很丟臉,尊嚴也是規格的一環
- Wi-Fi 版留給 C6:這臺不需要網路是優點;若要跌倒上報走 Wi-Fi,同程式骨架遷到 C6 SuperMini(系列第 04 篇有移植對照)
📦 檔案清單
cane-radar/
├── src/
│ └── cane_radar.ino # 完整韌體(本頁)
├── test/
│ └── radar-bench.ino # 皮尺校準模式:只印距離不分區
├── stl/
│ ├── pole-clamp.stl # 杖夾(Ø20–26mm)
│ ├── radar-case.stl # 主機殼(雙窗 16mm)
│ ├── window-cap.stl # 防水壓蓋
│ └── motor-collar.stl # 握把震動墊座(TPU)
├── f3d/
│ └── cane-radar.f3d # Fusion 360 原始檔
└── manual/
├── assembly.html # 圖文組裝說明書
└── family-card.html # 給長輩的一頁使用簡卡(A6)