📋 專案概述

你爸爸 68 歲,白內障術後兩個月,視力表能看清 0.6,但那是坐著、順光、專心的成績。真正出事的是黃昏的社區公園:逆光那陣子,他的世界對比度塌了一半——機車把手、花圃邊緣、沒蓋好的水溝蓋,這些「膝蓋到腰之間」的東西出現在他腳前的時間,比他認出它的時間晚了半秒。這半秒你沒見過,但上個月他手腕瘀青那次你見過了。你買過一支 NTT 三千的「電子導盲杖」試水溫:整支杖內嵌雷達,聽起來很未來,實際上重 780g、充電口在杖尾(每次充電要整支倒插)、警示是「嗶嗶嗶」三連音——在公園裡被圍棋阿公們注目,他用兩次就收進陽臺。問題從頭到尾不是「他需要機器人」,是他手上那支用了八年、長度調好、握把有手掌記憶的登山杖,要能早半秒知道前方有東西——而且不能用聽的,公園環境音太吵,聽覺在老人身上本來就是劣勢頻道,要用握把上的震動說話。

市售商品:

本專案功能清單:

💰 成本效益: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 SuperMini1~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Ω×21 組~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 VIN ● 電池 3.7–4.2V 3V3 ● 感測器電源 GND ● — GPIO6 Trig — GPIO7 Echo — GPIO2 馬達 — GPIO3 蜂鳴 — GPIO1 按鈕 — GPIO0 電壓 HC-SR04-33 Trig←GPIO6(10µs 脈衝) Echo→GPIO7(3.3V 準位) VCC→3V3 軌(3.3–5V 皆可) 震動馬達(握把側) GPIO2→1k→S8050 基極 馬達+→3V3 −→集極 1N4148 反並跨馬達(續流) 按鈕+下拉 10k 按下→3V3→GPIO1 深睡喚醒:ANY_HIGH 蜂鳴器模組 SIG←GPIO3(S8050) VCC→3V3(5V 版改接 5V) 電池監視 BAT+→100k→GPIO0→100k→GND 分壓一半,韌體讀值 ×2 電源鏈 LiPo→開關→板 VIN/TP4056 充電
圖說:LiPo 直接進 SuperMini 的 VIN(板上 LDO 壓到 3.3V),全程無射頻、無升壓——這是它能用小電池跑一整天的原因。馬達與蜂鳴器都吃 3V3 軌,靠 S8050 開關而非供電開關。
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 全部共地 黑 —

📳 回饋分級設計

silent >150cm 注意 150–90cm 停 <45cm 90cm 45cm 震動樣式(握把皮膚讀取): 注意 ▮········▮········ 1.0s 一脈衝 80ms 減速 ▮··▮··▮··▮·· 450ms 一脈衝(90–45cm 區段) 停  ▮▮▮▮▮▮▮ 連續震動+蜂鳴器間歇嗶 遲滯 ±5cm 防邊界跳變
圖說:分級的靈魂是「頻率=距離」。80ms 短脈衝隔着登山手套仍清晰可辨;連續震動區段同時點蜂鳴器,是同行人也聽得到的雙重保險。

🧊 3D 列印設計

列印件尺寸/數量材料與設定時間
杖夾(口徑 Ø20–26mm 彈性卡榫+M3 螺絲鎖環)1PETG、0.2mm、壁 4 層(受力件)、花紋 40%55m
主機殼(超聲波雙窗 16mm、側窗留 TP4056 充電指示燈與 USB-C 孔)1PETG、0.2mm、窗框薄壁 0.8mm 留感測靈敏度1h15m
防水蓋(感測窗壓蓋,矽膠圈槽)1PETG+矽膠條 1.5mm(或熱熔膠替代)18m
握把震動墊座(馬達貼環)1TPU 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 邊界的草叢會讓你手腕抽經。

🔧 組裝重點速覽

  1. 先用紙箱+雙面膠做「醜版」戴在杖上走一天,確認震動隔手套辨得出三級,再列印正式件。
  2. 感測窗朝向:雙管軸線保持水平朝正前方 5 度下傾——朝太多會全程看地,朝太少只看遠山。
  3. 量測校準:拿皮尺在 45/90/150cm 各貼地標站一次,序列埠讀數與實際差 >8cm 再調 Z_* 常數。
  4. 120 秒深睡測試:開機靜置兩分鐘不動,馬達應安靜到可以被按醒;醒來雙短震。
  5. 下水測試:噴霧瓶對感測窗噴到掛水珠,觀察它是否「以為 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
序列埠電壓讀值亂跳 ±80mVADC 參考噪聲(未接濾波)GPIO0 對地加 100nF;或改開機量一次+每 5 分鐘平均
深睡後待機電流量到 2mATP4056 還插著 USB(指示燈一直在)/開關位置不對拔線再量;開關必須切在電池與 VIN 之間
SuperMini 燒不進去Windows 驅動/埠佔用按住 BOOT 點上傳、「Writing」瞬間放開;換一條「有數據芯」的 USB-C 線(很多線只通電)
用了兩週開始誤報杖夾鬆了,感測頭角度下栽看地M3 鎖環重新鎖 1.5N·m(手感=轉緊再 1/4 圈);卡榫內貼 0.5mm 矽膠墊

🚀 進階延伸

📦 檔案清單

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)
← 上一篇:ESP32-C2 雨天傘架 📚 系列索引 下一篇:敬請期待 →