📋 專案概述

表哥在新北市包租一棟 5 樓公寓,某期水費單跳出 2,140 元——平常整棟才 600 元。查了一週才發現:頂樓馬桶的水栓橡皮閥整天細細地漏,一天 1.2 噸,三個月漏掉一台 iPhone。

最扯的不是漏水本身,是發現的時機:台北自來水兩個月才抄表一次,任何漏水都至少「漏滿 60 天」才會在帳單上被你看到。市售方案查了一圈:工業級脈衝水表讀取器(LoRa 加發報器)一台 1,500 元起,整合平台再收年費;而自家牆上那顆機械水錶,本質上就是「每轉 1 公升的發電機」——錶盤裡那顆磁鐵,配一顆 15 元的磁簧開關就能讀。

成本盤一盤:ESP32-C6 開發板 55 元、磁簧 15 元、18650 與電源料 112 元、其餘雜項 22 元,總計約 204 元。表哥那期多繳的 1,540 元,夠買七台——如果早三個月裝,一分錢都不用多繳。

本專案功能清單:

為什麼選 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 元必選有保護版,無保護 = 過放危險
LDOXC6206P332(3.3V)1~5 元待機 1µA;別用 AMS1117(待機 5mA 殺電池)
電池18650 2600mAh + 平頭電池座1~90 元拆機動力電池可再省,但要篩內阻
分壓電阻100kΩ ×22~2 元電池電壓量測(4.2V→2.1V)
蜂鳴器有源 3.3V1~8 元按鍵回饋 + 漏水警報
按鍵6×6 微動開關1~2 元殼外即按即報
其他不鏽鋼束帶 8×120 ×2、熱熔膠、防水標籤1 組~15 元
合計~204 元商用脈衝讀取器 1,500 元起 + 年費

🔌 接線圖

ESP32-C6 DevKitC-1 · Wi-Fi 6 USB-C(僅燒錄用) GPIO2 GPIO3 GPIO0 GPIO4 3V3 GND 0000 m³ 磁鐵(隨指針轉) MC-38 磁簧 機械水錶(夾臂固定) 黃 GPIO2 ↔ 磁簧 A 黑 磁簧 B ↔ GND 按鍵 綠 GPIO3 ↔ 按鍵(另端 GND) 🔊 有源蜂鳴器 3.3V 橘 GPIO4 → + 18650 + TP4056 → XC6206 3.3V → 板 3V3 100kΩ 100kΩ → GND 紫:分壓中點 → GPIO0(ADC 量電壓)
▲ 磁簧兩條線、按鍵兩條、蜂鳴器兩條、分壓三條——共 9 條。磁簧無極性,接反照樣計數。

詳細接線

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 / GNDLDO 輸出、共地電池不可直供 3V3:4.2V 超過 C6 上限
⚠️ 磁簧線不要接 5V 側再進 GPIO;MC-38 只是開關,一端接 GPIO、一端接 GND 才是正確接法,接在電源上會直接短路。

⚡ 省電架構

深睡 30µA 磁鐵經過拉低 GPIO2 喚醒 0.3s 去抖 10ms 計數 +1 判斷 0.01s 深夜?連續流? 事件才連線 漏水警報 / 6h 定時報 再睡 一般脈衝全程 < 0.4 秒、不開 Wi-Fi;只有警報與定時任務才吃連線的電
▲ 平均電流估算:深睡 0.15mA(含板與 LDO 損耗)+ 每日 4 次定時連線 × 3s × 80mA ≈ 0.8mA → 2600mAh ≈ 4 個月。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
主機殼(電池艙 + 主板艙 + 按鍵孔)1PETG灰/白~2.5 hr
殼蓋(卡扣 + 1.2mm 墊圈槽)1PETG灰/白~1 hr
磁簧可調夾臂1PETG橘(醒目、掉水錶井好找回)~25 min
束帶座2PETG灰~15 min ×2

列印設定

設計要點

  1. 夾臂 15mm 上下可調:各家水錶錶盤直徑不同,滑槽 + 翼形螺帽對準磁鐵行程
  2. 電池艙與主板艙分隔:中間 3mm 隔牆,TP4056 熱焊點與水氣來源隔離
  3. 纜線出口 ×2(5mm)+ 熱熔膠灌注孔:磁簧線出口朝下,水珠順勢滴落不沿線爬進殼
  4. 透明視窗 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 組裝說明書
  1. 列印零件:全組 PETG;殼蓋填充 100%
  2. 焊磁簧線:MC-38 接 50cm 雙芯線,接點熱熔膠全包覆(磁簧是玻璃殼,折弯會破)
  3. 電源組裝:TP4056 → 電池座 → XC6206 → 主板 3V3;先量 LDO 輸出 3.3V 再插板
  4. 燒錄與待機電流測試:燒錄後串 DC 電流表,深睡應 < 0.3mA;超標通常是充電板 LED 或分壓接錯
  5. 找磁鐵、鎖夾臂:備用磁鐵沿錶盤外圈慢慢劃,感到吸力處標記;夾臂鎖束帶對準標記
  6. 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 伺服器

🎯 進階延伸

📦 檔案清單

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
← 上一篇:ESP32-S3 人流計數相機 📚 系列索引 下一篇:ESP32-S3 包裹守望者 →