📋 專案概述

冬天全家輪流感冒,小兒科醫師交代了一句「洗手比補品有用」——然後我看著浴室那罐按壓式洗手乳:泵口一圈乾掉的皂垢,五歲的小孩寧可碰蟑螂也不碰它。我當時想,買一台感應式的就好了,查完一圈發現事情沒這麼簡單:

  1. 綁耗材墨盒:機器 690 元看起来很便宜,專用泡泡墨盒一盒 350 元、三個月一顆,斷貨就整台變磚——這是刮鬍刀賣刀的商業模式。
  2. 紅外窗片誤差:浴室的鏡子、水漬、皂垢全糊在同一塊紅外發射窗上,靈敏度一路漂——手伸進去沒反應、手收回來它狂出皂。
  3. 誤觸發:貓跳上洗手台 = 免費幫貓洗澡;半夜經過,它以為你要洗手。

這個專案從痛點反推規格:觸發改用 VL53L0X 測距(不是反射式紅外)——皂垢、肤色、黑手全都不影響飛行時間測距;再加一顆 JSN-SR04T 防水探頭從瓶蓋往下量皂位,剩 10% 直接 Telegram 推播,徹底告別「按到空了才知道沒皂」:

為什麼選 ESP32-C2?

需求C2 的優勢
省電(第一需求)電池產品 99% 的時間在睡,C2 深睡電流全系列最低梯隊,待機账以「年」計
價格C2 SuperMini ~35 元;浴室這種「撞倒就不可惜」的位置,成本就是正義
除錯友善BLE 5:量產裝機後用藍牙收日誌,不用拆機插線
週邊剛好I2C(ToF)+ GPIO(超音波/馬達/按鍵),閃存 RAM 都用不完

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C2 SuperMini1~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Ω ×22~1 元電池電壓監測
指示WS2812B ×11~5 元狀態燈(藍/綠/紅)
按鍵6×6 微動1~5 元機側
列印材料PLA ~60g + TPU 密封墊 ~5g—~20 元TPU 做泵頭/探頭密封圈
合計~420 元一瓶賣場補充液另計 79 元

🔌 接線圖

ESP32-C2 SuperMini USB-C(除錯)/5V(供電) GPIO1 GPIO6/7 GPIO4/5 GPIO2 VL53L0X SDA/SCL GPIO1=INT 紫線 INT → GPIO1(喚醒源) 黃線 SDA/SCL → GPIO6/7 JSN-SR04T Trig→GPIO4 Echo→GPIO5 青線 Trig/Echo → GPIO4/5(探頭朝下入瓶) N20+蠕動泵 經 AO3400 紅線 GPIO2 → 100Ω → Gate(10k 下拉)
▲ 四組連線:ToF 中斷(紫)是唯一能喚醒深睡的訊號線;超音波與馬達只在醒著時活動。馬達電源走獨立線回升壓板。

詳細接線

ESP32-C2 腳位連接說明
GPIO1VL53L0X GPIO1(INT)設定「距離小於閾值」中斷,同時是深睡 EXT1 喚醒源(低有效)
GPIO6 / GPIO7VL53L0X SDA / SCLI2C 0,模組內建上拉,不必外加電阻
GPIO4 / GPIO5JSN-SR04T Trig / Echo模組 Echo 輸出 5V?JSN-SR04T 是 5V 位準,Echo 串 2k+1k 分壓再進 C2
GPIO2AO3400 閘極100Ω 串接 + 10kΩ 下拉;漏極接馬達+、馬達−接地,SS34 續流反並馬達兩端
GPIO3WS2812B 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 列印零件

零件清單

零件數量材質建議顏色列印時間
機身(含握把電池艙)1PLA白色~4 hr
泵頭夾(N20 法蘭卡槽)1PLA白色~35 min
瓶蓋法蘭(探頭孔)1PLA灰色~40 min
探頭密封墊圈1TPU黑色~15 min
透明窗片(可選)1PETG(透明)透明~20 min

列印設定

設計要點

  1. 模組化瓶身:不列印瓶——法蘭直接鎖現成 350ml 洗手乳廣口瓶,耗材自由就是本專案的護城河
  2. 探頭孔 φ16.2 過盈 + TPU 墊圈壓封:JSN-SR04T 探頭垂直液面,斜 3° 回波就丟
  3. 泵頭夾留退讓槽:蠕動泵壓輪間隙 0.1~0.25mm 是出皂量開開關的關鍵,夾具留螺絲微調孔
  4. 電池艙防潮:握把內預置矽膠乾燥包艙(浴室濕度是電子殺手),底蓋可拆換包

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
 *  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 組裝說明書
  1. 列印零件:5 個零件;墊圈務必 TPU、透明窗務必 PETG
  2. 泵頭組裝:N20 卡進泵頭夾,矽管入泵前灌滿皂液排氣(有氣泡出皂量會飄)
  3. 瓶蓋法蘭:探頭由上往下穿入,TPU 墊圈夾緊、鎖螺圈;膠圈外側再上一圈馬自達silicone
  4. 接線:照接線圖;Echo 分壓電阻直接焊在板背
  5. 電源測試:升壓板先空載調 5.0V,接板後量靜態電流(拔 TP4056 量)
  6. 校準:序列埠開 115200,量「滿瓶/空瓶」液面高度回填兩個常數
  7. 組機:機身合殼、鎖瓶、掛牆,放乾燥包到底蓋艙

🔧 問題排除

問題原因解決方法
待機掉電快VL53L0X 沒進中斷模式/充放模組自耗拔 TP4056 再量;sleep 電流 >50µA 檢查 I2C 上拉是否由 3V3 供電(正常)而模組是否 startContinuous
皂位永遠 100%探頭斜射液面、回波丟失探頭必須垂直液面;圓弧瓶身加列印導波筒
冬天出皂量變少皂液黏度上升SOAK_TIME_MS 加溫度補償曲線,或稀釋一倍
貓跳台誤觸閾值太寬TRIGGER_MM 15→12cm;二次確認已是第一道閘
按了沒出皂但馬達嗆一聲軟管脫落/皂塊卡泵堵轉保護已自動反轉退料;仍卡就拆泵頭清洗——蠕動泵唯一耗材是矽管,半年一換 5 元
WS2812 首燈亂閃5V 毛刺燈腳就近 100nF + 330Ω 閘極電阻,別省

🎯 進階延伸

📦 檔案清單

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                  # 說明文件
← 上一篇:ESP32-C6 恆溫智慧隔熱杯墊 📚 系列索引 下一篇:ESP32-S3 本地端 AI 語音助理 →