📋 專案概述
表哥在新北市包租一棟 5 樓公寓,某期水費單跳出 2,140 元——平常整棟才 600 元。查了一週才發現:頂樓馬桶的水栓橡皮閥整天細細地漏,一天 1.2 噸,三個月漏掉一台 iPhone。
最扯的不是漏水本身,是發現的時機:台北自來水兩個月才抄表一次,任何漏水都至少「漏滿 60 天」才會在帳單上被你看到。市售方案查了一圈:工業級脈衝水表讀取器(LoRa 加發報器)一台 1,500 元起,整合平台再收年費;而自家牆上那顆機械水錶,本質上就是「每轉 1 公升的發電機」——錶盤裡那顆磁鐵,配一顆 15 元的磁簧開關就能讀。
成本盤一盤:ESP32-C6 開發板 55 元、磁簧 15 元、18650 與電源料 112 元、其餘雜項 22 元,總計約 204 元。表哥那期多繳的 1,540 元,夠買七台——如果早三個月裝,一分錢都不用多繳。
本專案功能清單:
- 磁簧脈衝計數:1 脈衝 = 1 公升(以 12.6L 桶裝水實測校準,誤差 <3%)
- 深夜漏水偵測:凌晨 2~5 點出現水流 → Telegram 立即推播「你家在漏水」
- 連續流守衛:脈衝間隔 < 90 秒連續 20 次(≈ 30 分鐘水沒關)→ 警報,水龍頭忘關也能救
- 每天 08:00 日報:昨日用水量、本月累計、電池電壓;Home Assistant MQTT 同步
- 深睡 + 脈衝喚醒:平均電流 < 1mA,18650 2600mAh 續航約 4 個月
- 按鍵即按即報:按一下「嗶」一聲,立即回報當前讀數,不用等早報
為什麼選 ESP32-C6?
| 需求 | C6 的優勢 |
|---|---|
| 機電室訊號 | Wi-Fi 6(802.11ax)BSS Coloring + OFDMA:水泥牆後頭收得到、跟鄰居 30 台 AP 搶得到通道 |
| 電池供電 | 晶片深睡 ~30µA 級;RISC-V 開機到 Wi-Fi 連線 < 1.5s,每次醒來耗電壓到最低 |
| 未來性 | 同晶片支援 Thread/Zigbee:日後改 Matter 邊界裝置免換硬體 |
| 成本 | 開發板 55 元,比「Wi-Fi 模組 + MCU」雙晶片方案還便宜 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C6-DevKitC-1-N8(或 C6 SuperMini) | 1 | ~55 元 | Wi-Fi 6;C2 也能做,但機電室訊號差一截 |
| 脈衝感測 | MC-38 磁簧開關 | 1 | ~15 元 | 附備用磁鐵測試;線預留 50cm |
| 充電板 | TP4056 + DW01 保護版 | 1 | ~12 元 | 必選有保護版,無保護 = 過放危險 |
| LDO | XC6206P332(3.3V) | 1 | ~5 元 | 待機 1µA;別用 AMS1117(待機 5mA 殺電池) |
| 電池 | 18650 2600mAh + 平頭電池座 | 1 | ~90 元 | 拆機動力電池可再省,但要篩內阻 |
| 分壓電阻 | 100kΩ ×2 | 2 | ~2 元 | 電池電壓量測(4.2V→2.1V) |
| 蜂鳴器 | 有源 3.3V | 1 | ~8 元 | 按鍵回饋 + 漏水警報 |
| 按鍵 | 6×6 微動開關 | 1 | ~2 元 | 殼外即按即報 |
| 其他 | 不鏽鋼束帶 8×120 ×2、熱熔膠、防水標籤 | 1 組 | ~15 元 | |
| 合計 | ~204 元 | 商用脈衝讀取器 1,500 元起 + 年費 |
🔌 接線圖
詳細接線
| ESP32-C6 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO2 | 磁簧開關其中一端 | 另一端接 GND;內部上拉,磁鐵經過→閉合→拉低→EXT1 喚醒。GPIO2 屬 RTC GPIO,深睡才喚得醒 |
| GPIO3 | 按鍵(另端 GND) | 與 GPIO2 併入 EXT1 ANY_LOW;喚醒後讀腳位狀態分辨是脈衝還是按鍵 |
| GPIO4 | 蜂鳴器 + | 3.3V 有源蜂鳴器;負極接 GND |
| GPIO0 (ADC1_CH0) | 100k/100k 分壓中點 | 4.2V→2.1V,在 ADC 量程內;分壓常駐耗 18µA,詳見省電章節 |
| 3V3 / GND | LDO 輸出、共地 | 電池不可直供 3V3:4.2V 超過 C6 上限 |
⚠️ 磁簧線不要接 5V 側再進 GPIO;MC-38 只是開關,一端接 GPIO、一端接 GND 才是正確接法,接在電源上會直接短路。
⚡ 省電架構
- 計數不連線:99% 的喚醒只做「計數 + 判斷 + 睡」,Wi-Fi 電容(EN 腳拉低)全程斷電
- NVS 每 6 小時寫一次:Flash 寫入有壽命(約 10 萬次),每脈衝都存撐不過三年;RTC 記憶體計數 + 定時落盤是正解
- 分壓常駐 18µA:換 2MΩ 可降到 1µA,但 ADC 源阻抗過高讀數會飄——18µA 是準確與省電的甜蜜點
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 主機殼(電池艙 + 主板艙 + 按鍵孔) | 1 | PETG | 灰/白 | ~2.5 hr |
| 殼蓋(卡扣 + 1.2mm 墊圈槽) | 1 | PETG | 灰/白 | ~1 hr |
| 磁簧可調夾臂 | 1 | PETG | 橘(醒目、掉水錶井好找回) | ~25 min |
| 束帶座 | 2 | PETG | 灰 | ~15 min ×2 |
列印設定
- 材質:全組 PETG——水錶井濕度常態 80%+,PLA 吸濕後卡扣會粉化
- 層厚:0.2mm;殼蓋墊圈槽 0.16mm 確保密封面平整
- 填充:25%;殼蓋 100%(防滲)
- 密封:墊圈槽填 718 矽膠後壓蓋固化,開蓋換電池時墊圈留在槽內可重複使用
設計要點
- 夾臂 15mm 上下可調:各家水錶錶盤直徑不同,滑槽 + 翼形螺帽對準磁鐵行程
- 電池艙與主板艙分隔:中間 3mm 隔牆,TP4056 熱焊點與水氣來源隔離
- 纜線出口 ×2(5mm)+ 熱熔膠灌注孔:磁簧線出口朝下,水珠順勢滴落不沿線爬進殼
- 透明視窗 30×20mm:PETG 3 層壁當視窗,不用拆殼就能對水錶數字核對校準
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解解釋「為什麼」。板子選 Arduino IDE 的 "ESP32C6 Dev Module"(core 3.x)。
/* ============================================================
* ESP32-C6 智慧水表脈衝偵測器 | Smart Water Meter
* ------------------------------------------------------------
* 功能:磁簧脈衝計數(1 脈衝=1L)、深夜漏水 Telegram 警報、
* 連續流守衛、每日 08:00 日報 + HA MQTT、按鍵即按即報
* 硬體:ESP32-C6-DevKitC-1 + MC-38 磁簧(GPIO2) + 按鍵(GPIO3)
* + 有源蜂鳴器(GPIO4) + 100k/100k 電池分壓(GPIO0)
* 供電:18650 → XC6206 3.3V LDO(Wi-Fi 未使用時 EN 斷電)
* 相依:Arduino core for ESP32 >= 3.0(WiFi/HTTPClient 內建)
* ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <esp_sleep.h>
#include <time.h>
/* ------------------------------------------------------------
* 1. 使用者設定區 —— 全篇只有這段需要修改
* ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_SSID";
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* MQTT_HOST = "192.168.1.10"; // HA 主機(無 HA 可忽略 MQTT 區塊)
const int MQTT_PORT = 1883;
/* ------------------------------------------------------------
* 2. 腳位與校準
* ------------------------------------------------------------ */
const int PULSE_PIN = 2; // 磁簧:閉合拉低(RTC GPIO,深睡可喚醒)
const int BTN_PIN = 3; // 按鍵:按下拉低
const int BUZZ_PIN = 4; // 有源蜂鳴器
const int VBAT_PIN = 0; // ADC1_CH0:100k/100k 分壓中點
float L_PER_PULSE = 1.0f; // 每脈衝公升數。出廠假設 1L,用 12.6L 桶裝水校準:
// 倒一桶水,看序列埠脈衝數,L_PER_PULSE = 12.6 / 脈衝數
/* ------------------------------------------------------------
* 3. 行為參數
* ------------------------------------------------------------ */
const uint32_t SLEEP_INTERVAL_US = 6UL * 3600UL * 1000000UL; // 6 小時定時喚醒(存檔+MQTT)
const uint32_t LEAK_GAP_S = 90; // 脈衝間隔 < 90s 視為「連續流」
const uint8_t LEAK_STREAK = 20; // 連續流 20 次(≈30 分鐘)→ 警報
const uint8_t QUIET_H0 = 2, QUIET_H1 = 5; // 深夜時段:任何水流都值得發訊
const uint32_t LEAK_COOLDOWN_S = 6 * 3600; // 漏水警報冷卻:同事件一天最多 4 則
/* ------------------------------------------------------------
* 4. 深睡跨週期狀態(RTC 記憶體:深睡不遺失、斷電遺失)
* 斷電風險由每 6 小時 NVS 落盤兜底,最多丟 6 小時脈衝
* ------------------------------------------------------------ */
RTC_DATA_ATTR uint32_t totalPulses = 0; // 開機以來總脈衝
RTC_DATA_ATTR uint32_t dayPulses = 0; // 今日脈衝
RTC_DATA_ATTR uint32_t yesterdayPulses= 0;
RTC_DATA_ATTR uint32_t monthPulses = 0;
RTC_DATA_ATTR uint32_t lastPulseUnix = 0; // 上次脈衝時間(算間隔)
RTC_DATA_ATTR uint8_t leakStreak = 0; // 連續流計數
RTC_DATA_ATTR uint32_t lastLeakAlert = 0; // 上次漏水警報時間
RTC_DATA_ATTR uint32_t lastReportDay = 0; // 日報去重(yyyymmdd)
Preferences nvs;
/* ------------------------------------------------------------
* connectWifi —— 連線並校時
* 回傳:是否成功(最多等 20 秒)
* 為什麼校時:SNTP 校準後 time() 才有意義,
* 「深夜時段」「08:00 日報」判斷全靠它
* ------------------------------------------------------------ */
bool connectWifi() {
if (WiFi.status() == WL_CONNECTED) return true;
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(500);
if (WiFi.status() != WL_CONNECTED) return false;
configTime(8 * 3600, 0, "pool.ntp.org", "time.google.com");
struct tm t;
for (int i = 0; i < 10; i++) { // 等 SNTP 回包(最多 5 秒)
delay(500);
time_t now = time(nullptr);
if (localtime_r(&now, &t) && t.tm_year > 100) return true; // tm_year>100 = 年份 2001 後 = 校時成功
}
return true; // 校時失敗仍回傳:發訊照發,時間欄標未同步
}
/* ------------------------------------------------------------
* sendTelegram —— 推播
* 參數:message;回傳:是否成功(HTTP 200)
* ------------------------------------------------------------ */
bool sendTelegram(const String& msg) {
HTTPClient http;
http.begin(String("https://api.telegram.org/bot") + TG_BOT_TOKEN + "/sendMessage");
http.addHeader("Content-Type", "application/json");
String json = String("{\"chat_id\":\"") + TG_CHAT_ID + "\",\"text\":\"" + msg + "\"}";
bool ok = (http.POST(json) == 200);
http.end();
return ok;
}
/* ------------------------------------------------------------
* batteryMv —— 量電池電壓
* 回傳:millivolts(分壓比 2,已補償 LDO 前壓降)
* 為什麼取中位數 5 次:Wi-Fi 突波會讓 ADC 基線抖 30mV
* ------------------------------------------------------------ */
uint16_t batteryMv() {
uint16_t v[5];
for (int i = 0; i < 5; i++) { v[i] = analogReadMilliVolts(VBAT_PIN); delay(5); }
for (int i = 1; i < 5; i++) { uint16_t k = v[i]; int j = i - 1;
while (j >= 0 && v[j] > k) { v[j+1] = v[j]; j--; } v[j+1] = k; }
return v[2] * 2;
}
/* ------------------------------------------------------------
* buildStatus —— 組狀態訊息(日報與按鍵共用)
* ------------------------------------------------------------ */
String buildStatus(bool daily) {
uint32_t mv = batteryMv();
int pct = constrain(map((int)mv, 3300, 4150, 0, 100), 0, 100);
String r = daily ? "💧 水表日報\n" : "💧 水表即時\n";
r += "昨日 " + String(yesterdayPulses * L_PER_PULSE, 0) + " L\n";
r += "本月 " + String(monthPulses * L_PER_PULSE, 0) + " L\n";
r += "累計 " + String(totalPulses * L_PER_PULSE, 0) + " L\n";
r += "🔋 " + String(pct) + "% (" + String(mv) + " mV)";
return r;
}
/* ------------------------------------------------------------
* mqttPublish —— 發一條 retained MQTT(HA 感測器)
* 參數:topic 尾段 / 值字串
* 注意:用原生 TCP 發最精簡 MQTT PUBLISH,
* 省掉 PubSubClient 體積——深睡裝置每 KB RAM 都珍貴
* ------------------------------------------------------------ */
bool mqttPublish(const char* sub, const String& val) {
WiFiClient sock;
if (!sock.connect(MQTT_HOST, MQTT_PORT)) return false;
// CONNECT 封包(client id "c6meter", clean session)
uint8_t conn[] = {0x10,0x11,0x00,0x04,'M','Q','T','T',0x02,0x00,0x3C,0x00,0x08,'c','6','m','e','t','e','r'};
sock.write(conn, sizeof(conn)); delay(50);
String topic = String("home/c6meter/") + sub;
int tlen = topic.length(), vlen = val.length();
int rem = 2 + tlen + vlen + 3;
uint8_t hdr[] = {0x30, (uint8_t)rem, (uint8_t)(tlen >> 8), (uint8_t)tlen};
sock.write(hdr, 4);
sock.print(topic);
sock.write((uint8_t)0x80 | (vlen >> 8)); // retained + 屬性長度
sock.write((uint8_t)vlen);
sock.print(val);
delay(100); // 等封包進出棧再斷線
sock.stop();
return true;
}
/* ------------------------------------------------------------
* handlePulse —— 脈衝事件:去抖、計數、漏水判斷
* 喚醒源是磁簧閉合,進來時腳位已是低電位
* ------------------------------------------------------------ */
void handlePulse() {
delay(10); // 磁簧彈跳約 1ms,10ms 去抖足夠
if (digitalRead(PULSE_PIN) != LOW) return; // 10ms 後已彈開 = 雜訊,不計
totalPulses++; dayPulses++; monthPulses++;
uint32_t now = time(nullptr);
uint32_t gap = (lastPulseUnix && now > lastPulseUnix) ? now - lastPulseUnix : 99999;
lastPulseUnix = now;
// 連續流計數:間隔 < 90s 累加,否則歸零
leakStreak = (gap < LEAK_GAP_S) ? min((int)leakStreak + 1, 255) : 1;
struct tm t; bool timeOk = localtime_r(&now, &t) && t.tm_year > 100;
bool quietHour = timeOk && t.tm_hour >= QUIET_H0 && t.tm_hour < QUIET_H1;
// 警報條件:深夜任何水流,或連續流達標;冷卻期內不洗版
bool leak = (quietHour || leakStreak >= LEAK_STREAK);
if (leak && now - lastLeakAlert > LEAK_COOLDOWN_S) {
for (int i = 0; i < 3; i++) { digitalWrite(BUZZ_PIN, HIGH); delay(120); digitalWrite(BUZZ_PIN, LOW); delay(120); }
if (connectWifi()) {
String why = quietHour ? "深夜 2~5 點偵測到水流" : "連續 30 分鐘不間斷用水";
sendTelegram("🚨 漏水警報!" + why + "\n月累計 " + String(monthPulses * L_PER_PULSE, 0) + " L");
}
lastLeakAlert = now;
leakStreak = 0;
}
// 等磁鐵離開再睡:不等會「閉合狀態」立刻再喚醒,空轉耗電
uint32_t t0 = millis();
while (digitalRead(PULSE_PIN) == LOW && millis() - t0 < 2000) delay(10);
}
/* ------------------------------------------------------------
* timerWork —— 每 6 小時:NVS 落盤 + MQTT;08:00 那次加發日報
* ------------------------------------------------------------ */
void timerWork() {
nvs.putU32("total", totalPulses);
nvs.putU32("month", monthPulses);
nvs.putU32("day", dayPulses);
uint32_t now = time(nullptr);
struct tm t;
bool timeOk = localtime_r(&now, &t) && t.tm_year > 100;
if (timeOk) {
uint32_t today = (t.tm_year + 1900) * 10000UL + (t.tm_mon + 1) * 100UL + t.tm_mday;
if (today != lastReportDay) { // 跨日:結算昨日
yesterdayPulses = dayPulses; dayPulses = 0;
if (t.tm_mday == 1) monthPulses = 0; // 跨月:歸零月累計
lastReportDay = today;
if (t.tm_hour >= 8) { // 8 點後的第一次喚醒補發日報(深睡可能錯過整點)
if (connectWifi()) sendTelegram(buildStatus(true));
}
}
}
if (connectWifi()) {
mqttPublish("liters_month", String(monthPulses * L_PER_PULSE, 0));
mqttPublish("battery_mv", String(batteryMv()));
}
}
/* ------------------------------------------------------------
* goSleep —— 佈防再睡
* EXT1:磁簧(GPIO2) 或 按鍵(GPIO3) 任一拉低喚醒
* 定時器:6 小時保底喚醒(存檔/校時/日報)
* ------------------------------------------------------------ */
void goSleep() {
gpio_set_pull_mode((gpio_num_t)PULSE_PIN, GPIO_PULLUP_ONLY); // 深睡期間維持上拉,
gpio_set_pull_mode((gpio_num_t)BTN_PIN, GPIO_PULLUP_ONLY); // 否則浮空腳會誤喚醒+漏電
esp_sleep_enable_ext1_wakeup((1ULL << PULSE_PIN) | (1ULL << BTN_PIN), ESP_EXT1_WAKEUP_ANY_LOW);
esp_sleep_enable_timer_wakeup(SLEEP_INTERVAL_US);
Serial.println("→ 進入深睡");
esp_deep_sleep_start(); // 不返回
}
void setup() {
Serial.begin(115200);
delay(300);
pinMode(PULSE_PIN, INPUT_PULLUP);
pinMode(BTN_PIN, INPUT_PULLUP);
pinMode(BUZZ_PIN, OUTPUT); digitalWrite(BUZZ_PIN, LOW);
analogSetAttenuation(ADC_11db); // 量程拉到 3.3V,2.1V 分壓點才量得到
nvs.begin("meter", false);
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
if (cause == ESP_SLEEP_WAKEUP_UNDEFINED) { // 首次上電/斷電重啟:從 NVS 撈回總量
totalPulses = nvs.getU32("total", 0);
monthPulses = nvs.getU32("month", 0);
dayPulses = nvs.getU32("day", 0);
Serial.println("冷啟動:已從 NVS 復原計數");
}
bool btnPressed = (digitalRead(BTN_PIN) == LOW);
if (btnPressed) { // 按鍵喚醒:立即回報,不等早報
digitalWrite(BUZZ_PIN, HIGH); delay(80); digitalWrite(BUZZ_PIN, LOW);
if (connectWifi()) sendTelegram(buildStatus(false));
} else if (cause == ESP_SLEEP_WAKEUP_TIMER) {
timerWork();
} else { // EXT1:脈衝或按鍵混在一起,讀腳位分辨
if (digitalRead(PULSE_PIN) == LOW) handlePulse();
}
goSleep();
}
void loop() { /* 不返回 setup 之後的 goSleep(),loop 留空 */ }
⚠️
RTC_DATA_ATTR 存在 RTC 記憶體:換電池 = 斷電 = 計數歸零,所以每 6 小時落盤 NVS;換電池前請手動按鍵回報一次,把最後 6 小時補齊。🏗️ 組裝步驟
📘 圖文版組裝說明書:IKEA 風格 6 步驟卡片、SVG 示意圖、找磁鐵與 12.6L 校準流程,可直接列印 A4 帶到水錶前 → 開啟 HTML 組裝說明書
- 列印零件:全組 PETG;殼蓋填充 100%
- 焊磁簧線:MC-38 接 50cm 雙芯線,接點熱熔膠全包覆(磁簧是玻璃殼,折弯會破)
- 電源組裝:TP4056 → 電池座 → XC6206 → 主板 3V3;先量 LDO 輸出 3.3V 再插板
- 燒錄與待機電流測試:燒錄後串 DC 電流表,深睡應 < 0.3mA;超標通常是充電板 LED 或分壓接錯
- 找磁鐵、鎖夾臂:備用磁鐵沿錶盤外圈慢慢劃,感到吸力處標記;夾臂鎖束帶對準標記
- 12.6L 校準:序列埠開著,倒一整桶 12.6L 桶裝水,依脈衝數回填
L_PER_PULSE重燒;連續流測試:開水龍頭 35 分鐘收警報即通驗
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 完全不計數 | 磁鐵位置沒對到 / 磁簧玻璃殼破了 | 重劃磁鐵行程;萬用表量閉合時應導通 |
| 計數是實際 2~3 倍 | 磁簧彈跳 / 相鄰齒輪磁鐵都掃過 | 去抖已 10ms;把夾臂移離只讓「最快那顆指針」的磁鐵進入感應圈 |
| 半夜誤報 | 深夜定時制水機/淨水器反沖洗 | 反沖洗只有一兩分鐘,把 LEAK_STREAK 拉高或把 QUIET 時段水流改為「間隔<300s 才報」 |
| 續航掉一半 | TP4056 仍在充電(LED 恆亮)/ 分壓接錯 | 充飽拔線後待機應 < 0.3mA;超標依序查充電板、LDO 待機、分壓線 |
| 喚醒後死機重啟迴圈 | 電池電壓掉到 3.4V 以下,Wi-Fi 突波壓垮 LDO | 充電;日報出現 🔋 <15% 就該充了 |
| 日報時間錯亂 | SNTP 校時失敗(機電室封閉) | 程式已容錯:8 點後首次喚醒補發;仍錯就加備用 NTP 伺服器 |
🎯 進階延伸
- 雙錶版:C6 剩餘 GPIO 再掛一路磁簧,自來水 + 地下水分開計
- 電磁閥連動:連續流警報升級 → GPIO 驅動 12V 球閥自動斷水(先加手動復歸開關)
- Thread 化:改 esp-idf Thread 範例走 Matter,直接進 Apple Home 生態系
- 太陽能:機電室有窗的話,2V 50mA 小板 + TP4056,徹底免拆殼充電
📦 檔案清單
smart-water-meter/
├── src/
│ └── smart-water-meter.ino # 完整程式碼(本頁)
├── stl/
│ ├── meter-case.stl # 主機殼(PETG)
│ ├── meter-cover.stl # 殼蓋(100% 填充)
│ ├── reed-arm.stl # 磁簧可調夾臂
│ └── strap-mount.stl # 束帶座 ×2
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md