📋 專案概述
冬天全家輪流感冒,小兒科醫師交代了一句「洗手比補品有用」——然後我看著浴室那罐按壓式洗手乳:泵口一圈乾掉的皂垢,五歲的小孩寧可碰蟑螂也不碰它。我當時想,買一台感應式的就好了,查完一圈發現事情沒這麼簡單:
- 綁耗材墨盒:機器 690 元看起来很便宜,專用泡泡墨盒一盒 350 元、三個月一顆,斷貨就整台變磚——這是刮鬍刀賣刀的商業模式。
- 紅外窗片誤差:浴室的鏡子、水漬、皂垢全糊在同一塊紅外發射窗上,靈敏度一路漂——手伸進去沒反應、手收回來它狂出皂。
- 誤觸發:貓跳上洗手台 = 免費幫貓洗澡;半夜經過,它以為你要洗手。
這個專案從痛點反推規格:觸發改用 VL53L0X 測距(不是反射式紅外)——皂垢、肤色、黑手全都不影響飛行時間測距;再加一顆 JSN-SR04T 防水探頭從瓶蓋往下量皂位,剩 10% 直接 Telegram 推播,徹底告別「按到空了才知道沒皂」:
- 伸手 15cm 內 → 出皂 2.5 秒,距離閾值與出皂時數全參數化
- 深睡 30µA 等 ToF 中斷喚醒:18650 供電,一次充電用 3~4 個月
- 堵轉保護:出皂期間量測電源軌壓降,泵被皂塊卡住立即停機 + 反轉退料重試,不燒馬達不潰漏
- 每日洗手次數上報 NAS:小孩到底有沒有洗手,日誌說話
- 機身實體按鍵:長按手動補皂、短按靜音;低電壓自動禁 Wi-Fi 保住出皂本業
為什麼選 ESP32-C2?
| 需求 | C2 的優勢 |
|---|---|
| 省電(第一需求) | 電池產品 99% 的時間在睡,C2 深睡電流全系列最低梯隊,待機账以「年」計 |
| 價格 | C2 SuperMini ~35 元;浴室這種「撞倒就不可惜」的位置,成本就是正義 |
| 除錯友善 | BLE 5:量產裝機後用藍牙收日誌,不用拆機插線 |
| 週邊剛好 | I2C(ToF)+ GPIO(超音波/馬達/按鍵),閃存 RAM 都用不完 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C2 SuperMini | 1 | ~35 元 | GPIO1 當喚醒腳 |
| 測距 | VL53L0X ToF 模組 | 1 | ~55 元 | I2C;GPIO1 中斷可喚醒深睡 |
| 皂位 | JSN-SR04T 防水超音波 | 1 | ~65 元 | 探頭裝瓶蓋、朝下量液面 |
| 泵 | N20 減速馬達 + 蠕動泵頭 | 1 | ~95 元 | 6V;矽膠軟管 4×7mm |
| 驅動 | AO3400 MOSFET + SS34 續流二極體 | 各 1 | ~10 元 | 低邊切換,閘極 10k 下拉必裝 |
| 電池 | 18650(帶保護板) | 1 | ~75 元 | 裝握把內,換電從底部滑蓋 |
| 充放電 | TP4056(帶保護)+ 升壓 5616 | 各 1 | ~25 元 | 升壓輸出先調 5.0V 再進板 |
| 分壓電阻 | 100kΩ ×2 | 2 | ~1 元 | 電池電壓監測 |
| 指示 | WS2812B ×1 | 1 | ~5 元 | 狀態燈(藍/綠/紅) |
| 按鍵 | 6×6 微動 | 1 | ~5 元 | 機側 |
| 列印材料 | PLA ~60g + TPU 密封墊 ~5g | — | ~20 元 | TPU 做泵頭/探頭密封圈 |
| 合計 | ~420 元 | 一瓶賣場補充液另計 79 元 |
🔌 接線圖
詳細接線
| ESP32-C2 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO1 | VL53L0X GPIO1(INT) | 設定「距離小於閾值」中斷,同時是深睡 EXT1 喚醒源(低有效) |
| GPIO6 / GPIO7 | VL53L0X SDA / SCL | I2C 0,模組內建上拉,不必外加電阻 |
| GPIO4 / GPIO5 | JSN-SR04T Trig / Echo | 模組 Echo 輸出 5V?JSN-SR04T 是 5V 位準,Echo 串 2k+1k 分壓再進 C2 |
| GPIO2 | AO3400 閘極 | 100Ω 串接 + 10kΩ 下拉;漏極接馬達+、馬達−接地,SS34 續流反並馬達兩端 |
| GPIO3 | WS2812B DIN | 狀態燈:藍=開機、綠=就緒、紅=異常/低電 |
| GPIO8 | 按鍵 → GND | 內部上拉;同時掛進 EXT1 喚醒位元遮罩(長按手動出皂) |
| GPIO27 (ADC) | 電池分壓中點 | 100k/100k;堵轉偵測也讀這條軌(馬達啟動時的壓降會耦合進來) |
ASCII 速查(對照上圖)
ESP32-C2 SuperMini VL53L0X (ToF)
GPIO1 ─────────────────── GPIO1 (INT) ← 喚醒源
GPIO6 ──────┬──────────── SDA
GPIO7 ──────┴──────────── SCL
3V3 ─────────────────── VIN
GND ─────────────────── GND
GPIO4 ─────────────────── JSN-SR04T Trig
GPIO5 ←─[2k]─┬─[1k]─GND── JSN-SR04T Echo(5V→3.3V)
5V ─────────────────── JSN-SR04T V+
GPIO2 ──[100Ω]──┬───────── AO3400 Gate
└──[10k]── GND
升壓5V ──[SS34]──┬───────── N20 馬達(+) ─馬達(−)─ GND
└─ AO3400 Drain(Source 接地)
GPIO3 ──[330Ω]─────────── WS2812B DIN
GPIO8 ────(按鍵)──── GND 手動/喚醒
GPIO27 ────┬───[100k]─── 電池(+)
└───[100k]─── GND
⚠️ 馬達啟停會把 3.3V 軌拉出毛刺,升壓板出口加 470µF、電容放板子旁邊而不是電源端;馬達線與 I2C 訊號分兩側走線。續流二極體沒焊 = AO3400 壽命倒數。
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 機身(含握把電池艙) | 1 | PLA | 白色 | ~4 hr |
| 泵頭夾(N20 法蘭卡槽) | 1 | PLA | 白色 | ~35 min |
| 瓶蓋法蘭(探頭孔) | 1 | PLA | 灰色 | ~40 min |
| 探頭密封墊圈 | 1 | TPU | 黑色 | ~15 min |
| 透明窗片(可選) | 1 | PETG(透明) | 透明 | ~20 min |
列印設定
- 材質:PLA 为主体;接觸皂液與水的密封件用 TPU(PLA 長期泡水解體)
- 填滿:15%(機身)/100%(法蘭與墊圈,防水需要)
- 層厚:0.2mm/墊圈 0.12mm(減少層間滲漏)
- 壁厚:法蘭 2.4mm(3 層),鎖瓶口時不打滑
設計要點
- 模組化瓶身:不列印瓶——法蘭直接鎖現成 350ml 洗手乳廣口瓶,耗材自由就是本專案的護城河
- 探頭孔 φ16.2 過盈 + TPU 墊圈壓封:JSN-SR04T 探頭垂直液面,斜 3° 回波就丟
- 泵頭夾留退讓槽:蠕動泵壓輪間隙 0.1~0.25mm 是出皂量開開關的關鍵,夾具留螺絲微調孔
- 電池艙防潮:握把內預置矽膠乾燥包艙(浴室濕度是電子殺手),底蓋可拆換包
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
* ESP32-C2 智慧觸控洗手泡沫機 | Smart Foam Soap Dispenser
* ------------------------------------------------------------
* 功能:ToF 測距觸發出皂、超聲波皂位監測、深睡中斷喚醒、
* 堵轉保護、洗手次數上報 NAS、Telegram 剩皂推播
* 硬體:ESP32-C2 SuperMini + VL53L0X + JSN-SR04T + N20 蠕動泵
* 供電:18650 → 升壓 5V(待機 30µA,靠 EXT1 喚醒)
* 相依:Arduino core for ESP32 ≥3.0(選 ESP32C2 Dev Module)
* Pololu VL53L0X 函式庫、Adafruit NeoPixel
* ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <VL53L0X.h> // Pololu 版,支援 setGPIOInterrupts
#include <Adafruit_NeoPixel.h>
#include <Preferences.h> // NVS:跨深睡保存計數
/* ------------------------------------------------------------
* 1. 使用者設定區 —— 只有這段需要修改
* ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_SSID"; // 僅 2.4GHz
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TG_BOT_TOKEN = "YOUR_BOT_TOKEN";
const char* TG_CHAT_ID = "YOUR_CHAT_ID";
const char* NAS_URL = "http://192.168.8.120:8090"; // NAS 收計數端
/* ------------------------------------------------------------
* 2. 腳位與機電參數
* ------------------------------------------------------------ */
#define PIN_TOF_INT 1 // VL53L0X INT,EXT1 喚醒位
#define PIN_TRIG 4
#define PIN_ECHO 5
#define PIN_MOTOR 2 // PWM → AO3400
#define PIN_LED 3
#define PIN_BUTTON 8 // 也是 EXT1 喚醒位
#define PIN_VBATT 27 // 電池分壓中點(×2 補償)
const uint16_t TRIGGER_MM = 150; // 觸發距離:15cm(貓掠過用二次確認擋)
const uint16_t SOAK_TIME_MS = 2500; // 標準出皂時數
const uint16_t BLIND_MS = 8000; // 出皂後致盲期:手還在水龍頭下別加戲
const uint8_t STALL_DROP_MV = 600; // 啟動後電軌壓降超過 = 疑似堵轉
const float BOTTLE_EMPTY_CM = 3.0; // 探頭到瓶底淨高(裝機實測填入)
const float BOTTLE_FULL_CM = 16.5; // 滿瓶液面高度(同上)
/* ------------------------------------------------------------
* 3. 全域物件與狀態
* ------------------------------------------------------------ */
VL53L0X tof;
Adafruit_NeoPixel led(1, PIN_LED, NEO_GRB + NEO_KHZ800);
Preferences nvs;
uint32_t washCount = 0; // 當日洗手次數(NVS 持久化)
bool lowBattery = false;
/* ------------------------------------------------------------
* setLed —— 統一燈號出口
* 參數:r,g,b = 0~255;0,0,0 關燈
* 為什麼封裝:深睡前必關燈,留裸調用容易漏
* ------------------------------------------------------------ */
void setLed(uint8_t r, uint8_t g, uint8_t b) {
led.setPixelColor(0, r, g, b);
led.show();
delay(2); // WS2812 鎖存需要一點時間再睡眠
}
/* ------------------------------------------------------------
* readSoilLevel → readSoapPercent —— 量測剩皂百分比
* 回傳:0~100(-1 = 量測失敗)
* 做法:20 次回波取中位數;超聲波易受瓶內掛壁回波干扰,
* 中位數比平均數抗離群值
* ------------------------------------------------------------ */
float medianPing() {
uint32_t d[20], n = 0;
for (int i = 0; i < 20; i++) {
digitalWrite(PIN_TRIG, LOW); delayMicroseconds(4);
digitalWrite(PIN_TRIG, HIGH); delayMicroseconds(12);
digitalWrite(PIN_TRIG, LOW);
uint32_t us = pulseIn(PIN_ECHO, HIGH, 30000); // 30ms 逾時=丟失回波
if (us > 200) d[n++] = us;
delay(35); // 兩發之間留靜默期防餘波
}
if (n < 10) return -1; // 有效回波太少視為失敗
for (uint32_t i = 1; i < n; i++) { // 排序取中位數
uint32_t k = d[i]; int j = i - 1;
while (j >= 0 && d[j] > k) { d[j+1] = d[j]; j--; }
d[j+1] = k;
}
return d[n/2] / 58.0f / 2.0f; // µs → cm(空氣音速)
}
int soapPercent() {
float cm = medianPing();
if (cm < 0) return -1;
float pct = (BOTTLE_EMPTY_CM - cm) / (BOTTLE_EMPTY_CM - BOTTLE_FULL_CM) * 100.0f;
return constrain((int)pct, 0, 100);
}
/* ------------------------------------------------------------
* readBatteryMv —— 電池電壓(mV)
* 10 次取中位數;ADC 讀數先做 11dB 衰減補償
* ------------------------------------------------------------ */
uint16_t readBatteryMv() {
analogSetPinAttenuation(PIN_VBATT, ADC_11db);
uint32_t s[10];
for (int i = 0; i < 10; i++) { s[i] = analogReadMilliVolts(PIN_VBATT); delay(2); }
for (int i = 1; i < 10; i++) { uint32_t k = s[i]; int j = i-1;
while (j >= 0 && s[j] > k) { s[j+1] = s[j]; j--; } s[j+1] = k; }
return (uint16_t)(s[5] * 2); // ×2:100k/100k 分壓比
}
/* ------------------------------------------------------------
* dispenseSoap —— 帶堵轉保護的出皂
* 參數:ms = 目標出皂時長
* 回傳:true 正常完成/false 堵轉中止
* 做法:PWM 軟啟動防水錘;期間每 50ms 監聽電池軌壓降,
* 啟動後 400ms 起若壓降 > STALL_DROP_MV 連續 3 拍 → 堵轉
* ------------------------------------------------------------ */
bool dispenseSoap(uint16_t ms) {
uint16_t baseMv = readBatteryMv();
uint32_t t0 = millis();
// 軟啟動:0→255 爬 200ms,冷皂液黏度高,直接全壓會把軟管憋脫
for (int d = 0; d <= 255; d += 16) { ledcWrite(0, d); delay(12); }
int stallBeats = 0;
while (millis() - t0 < ms) {
delay(50);
uint16_t mv = readBatteryMv();
if (millis() - t0 > 400 && baseMv > mv + STALL_DROP_MV) {
if (++stallBeats >= 3) { // 連續 150ms 深度壓降=泵被皂塊卡死
for (int d = 255; d >= 0; d -= 32) ledcWrite(0, d);
ledcWrite(0, 0);
ledcWrite(1, 200); delay(120); // 反轉退料 100ms(需雙 MOSFET 者跳過)
ledcWrite(1, 0);
return false;
}
} else stallBeats = 0;
}
for (int d = 255; d >= 0; d -= 32) { ledcWrite(0, d); delay(6); } // 緩停防滴漏
ledcWrite(0, 0);
return true;
}
/* ------------------------------------------------------------
* reportNas —— 洗手統計與皂位上報
* 回傳:是否成功
* 注意:醒著才有 Wi-Fi;失敗退避重試 3 次就放棄(省電)
* ------------------------------------------------------------ */
bool reportNas(int level) {
if (WiFi.status() != WL_CONNECTED) return false;
HTTPClient http;
String url = String(NAS_URL) + "/soap_log?cnt=" + String(washCount) +
"&level=" + String(level) + "&vbatt=" + String(readBatteryMv());
for (int i = 0; i < 3; i++) {
if (http.begin(url)) {
int code = http.GET();
http.end();
if (code == 200) return true;
}
delay(300 * (i + 1)); // 線性退避,越近越難成功的請求越要慢
}
return false;
}
/* ------------------------------------------------------------
* sendTelegram —— 剩皂/低電推播(當日去重在上層做)
* ------------------------------------------------------------ */
void sendTelegram(const String& msg) {
if (WiFi.status() != WL_CONNECTED) return;
HTTPClient http;
http.begin(String("https://api.telegram.org/bot") + TG_BOT_TOKEN + "/sendMessage");
http.addHeader("Content-Type", "application/json");
String json = "{\"chat_id\":\"" + String(TG_CHAT_ID) + "\",\"text\":\"" + msg + "\"}";
http.POST(json);
http.end();
}
/* ------------------------------------------------------------
* tofConfirmTwice —— 喚醒後二次確認,擋貓與水花
* 回傳:true = 300ms 內兩次都 < TRIGGER_MM
* 為什麼:ToF 中斷只是一個「有可能」的訊號,深睡喚醒很便宜,
* 出皂很貴——第二閘門用 60ms 延遲換 99% 的誤觸率下降
* ------------------------------------------------------------ */
bool tofConfirmTwice() {
uint16_t a = tof.readRangeSingleMillimeters();
delay(60);
uint16_t b = tof.readRangeSingleMillimeters();
return (a < TRIGGER_MM && b < TRIGGER_MM && a < 8000 && b < 8000);
}
/* ------------------------------------------------------------
* setup —— 開機:区分冷啟動/喚醒,配置中斷後進深睡
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
nvs.begin("soap", true);
washCount = nvs.getUInt("cnt", 0);
pinMode(PIN_TRIG, OUTPUT); pinMode(PIN_BUTTON, INPUT_PULLUP);
ledcAttach(PIN_MOTOR, 20000, 8); // 20kHz 超聲頻段,馬達不啸叫
led.begin(); setLed(0, 0, 80); // 藍色=開機
// 馬達停、燈號之外的負載全關
Wire.begin(6, 7);
tof.init();
tof.setTimeout(500);
tof.setGPIOInterrupts(200, 1); // 距離 < 200mm 時 INT 拉低(略寬,軟體再收)
tof.startContinuous(100); // 低功耗連測 100ms 間隔
lowBattery = readBatteryMv() < 3300; // 低電禁 Wi-Fi 的總開關
if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_UNDEFINED) {
Serial.println("冷啟動"); // 上電:連一次 Wi-Fi 報平安
}
if (!lowBattery) {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
}
}
/* ------------------------------------------------------------
* loop —— 每次喚醒處理一件事,然後立刻回深睡
* 待機预算:30µA × 99.9% + 醒著 120mA × 0.1% ≈ 日耗 0.4mAh
* ------------------------------------------------------------ */
void loop() {
uint64_t cause = esp_sleep_get_ext1_wakeup_status();
bool manual = (cause & (1ULL << PIN_BUTTON)) || digitalRead(PIN_BUTTON) == LOW;
bool hand = !manual && tofConfirmTwice();
if (manual || hand) {
setLed(80, 255, 80); // 綠色準備出皂
bool ok = dispenseSoap(SOAK_TIME_MS);
if (ok) {
washCount++;
nvs.putUInt("cnt", washCount);
Serial.printf("出皂完成,今日第 %lu 次\n", washCount);
} else {
setLed(255, 0, 0); delay(300);
Serial.println("堵轉!已退出");
}
delay(BLIND_MS / 2); // 簡化致盲:醒著多留 4 秒再睡
}
// 順手體檢:皂位 + 電壓,低量推播
int level = soapPercent();
if (!lowBattery && WiFi.status() == WL_CONNECTED) {
reportNas(level);
if (level >= 0 && level < 10 && nvs.getUInt("lastAlertDay", 0) != millis() / 86400000UL) {
nvs.putUInt("lastAlertDay", millis() / 86400000UL);
sendTelegram("泡沫機剩皂不足 10%,請補充~");
}
}
setLed(0, 0, 0);
nvs.end();
// EXT1:ToF 中斷或按鍵拉低都會醒
esp_sleep_enable_ext1_wakeup((1ULL << PIN_TOF_INT) | (1ULL << PIN_BUTTON),
ESP_EXT1_WAKEUP_ANY_LOW);
esp_deep_sleep_start();
}
🔋 省電實測口诀:量待機電流請把 TP4056 拔掉(充放模組靜態自耗就有 60~100µA,會被它騙);實際測得 32µA 的功臣是 VL53L0X 連測模式下仍比「單發+輪詢」省,因為 CPU 從不甦醒到搬 I2C。
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 列印零件:5 個零件;墊圈務必 TPU、透明窗務必 PETG
- 泵頭組裝:N20 卡進泵頭夾,矽管入泵前灌滿皂液排氣(有氣泡出皂量會飄)
- 瓶蓋法蘭:探頭由上往下穿入,TPU 墊圈夾緊、鎖螺圈;膠圈外側再上一圈馬自達silicone
- 接線:照接線圖;Echo 分壓電阻直接焊在板背
- 電源測試:升壓板先空載調 5.0V,接板後量靜態電流(拔 TP4056 量)
- 校準:序列埠開 115200,量「滿瓶/空瓶」液面高度回填兩個常數
- 組機:機身合殼、鎖瓶、掛牆,放乾燥包到底蓋艙
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 待機掉電快 | VL53L0X 沒進中斷模式/充放模組自耗 | 拔 TP4056 再量;sleep 電流 >50µA 檢查 I2C 上拉是否由 3V3 供電(正常)而模組是否 startContinuous |
| 皂位永遠 100% | 探頭斜射液面、回波丟失 | 探頭必須垂直液面;圓弧瓶身加列印導波筒 |
| 冬天出皂量變少 | 皂液黏度上升 | SOAK_TIME_MS 加溫度補償曲線,或稀釋一倍 |
| 貓跳台誤觸 | 閾值太寬 | TRIGGER_MM 15→12cm;二次確認已是第一道閘 |
| 按了沒出皂但馬達嗆一聲 | 軟管脫落/皂塊卡泵 | 堵轉保護已自動反轉退料;仍卡就拆泵頭清洗——蠕動泵唯一耗材是矽管,半年一換 5 元 |
| WS2812 首燈亂閃 | 5V 毛刺 | 燈腳就近 100nF + 330Ω 閘極電阻,別省 |
🎯 進階延伸
- 雙泵版:洗手液 + 消毒液獨立艙,ToF 遠近兩段手勢選擇(12cm 出 A、25cm 出 B)
- Home Assistant:MQTT 上報皂位/電壓,低於閾值自動進買清單
- 兒童模式:第二顆 ToF 照手掌搓動,搓滿 20 秒燈環慶祝——洗手歌計時器
- 側面小 OLED:瓶身直接顯示剩餘%,Telegram 都能省(本方案先發推播,省 15 元)
📦 檔案清單
soap-dispenser/
├── src/
│ └── soap-dispenser.ino # ESP32 程式碼(本頁完整程式)
├── stl/
│ ├── body.stl # 機身(含握把電池艙)
│ ├── pump-clip.stl # 泵頭夾
│ ├── cap-flange.stl # 瓶蓋法蘭
│ └── gasket-tpu.stl # 探頭密封墊圈
├── f3d/
│ └── dispenser-design.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件