📋 專案概述

七月的某個週二,我提著澆花壺站在水龍頭前,旁邊是三天前下過大雨、裝得滿開的 190 公升省水桶——它就在我看不見的位置,而我對它的認知停留在那聲「咚」的落水聲。水價戶務處級距跳到第三級那個月,我在浴室盯著帳單算了半小時:一家四口夏季每月多繳 400 多元水費,而陽台那桶「免費的水」我這個月一次都用不到——因為我從不知道它剩多少,也從想不起來它存在。省水設備最大的失敗不是漏水,是遺忘。

第二個痛點更噁心,而且是那種要翻開桶蓋才會遇到的:桶底那層放了六七天、晒到微溫的水,白底黑點的孑孓正在裡面集體早自习。斑蚊在 25°C 的水裡,從卵到化蛹只要 5~7 天——「放一周的雨水」和「蚊子育嬰房」是同一個東西的兩個名字。你會倒掉它嗎?不會,那是 190 公升「免費的水」。你會加蓋嗎?桶蓋就靠在牆邊,但每桶都要掀開掀回去,人類做不超過三次。

市售方案我也搜過一圈:

  1. 進口雨水桶水位計(透明浮球尺+刻度貼):上千台幣,本質是一根會卡垢的塑料尺,還得蹲下去讀。資訊即時性=你記得去蹲的那個瞬間。
  2. 智慧水錶類 App:量的是自來水,它只會提醒你「這個月超用了」,不會告訴你「明明有水你為什麼在用自來水」。
  3. 什麼都不裝:零成本,結果就是我上面兩段——繳級距水費+幫邻居養蚊子。

而這三個數字——水位、水溫、存水天數——其實一顆最便宜的 ESP32 就能全算。這台 ESP32-C2 陽台雨水智慧量表做的事:

帳目:進口浮球水位計 ~1,000 元+你蹲下去讀的勞務;本機 約 540 元。回收計算很簡單:水價第三級與第二級每度差約 6 元,省水桶每月多澑掉兩桶自來水(約 4 度)就回本一半——但真正的回收率是蚊子的帳:一隻在白天的陽台咬你的花斑蚊,不會因為你裝了水位計就餓死,讓它死在孑孓階段的,是你終於記得換水這件事。

為什麼選 ESP32-C2?

需求C2 的優勢(以及其他型號為什麼不選)
每 30 分鐘醒 8 秒的待機帳單深睡電流 5µA 級(datasheet 典型值)、全系列最便宜的 Wi-Fi 晶片;這台的日常是「裝死 29 分 52 秒,醒來量個水」,多一分錢的晶片都是浪費
功能匹配沒有 BLE、沒有雙核、Flash 小——但一個澆水量表需要這些嗎?S3 的 PSRAM 在這裡每一 byte 都在繳笑聲稅;C6 的 Wi-Fi 6 對「一天連網 48 次、每次兩秒」的負載毫無意義。省下的 35 元拿去买更好的防水超聲波探頭
要付的代價(誠實欄)2MB Flash 塞完 Wi-Fi+mbedTLS+NVS 之後沒有 OTA 餘裕——定稿單 app 分割、關 OTA。陽台機器刷壞了爬一次陽台,比多一個遠端刷機攻擊面便宜。1MB 版模組請放棄本篇(TLS 緩衝會跟你搶 RAM/Flash)
腳位限制C2 的 ADC1 只落在 GPIO0–4,電池分壓這種模擬腳要事先卡位;本篇 GPIO0 讀電壓、GPIO3–6 給數位,剛好夠且互不搶劫

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C2-DevKitM-1(2MB Flash 版)1~75 元買模組時確認是 2MB 版;1MB 版塞不下 TLS
水位感測JSN-SR04T(防水探頭版)1~120 元一定要防水灌膠版,一般 HC-SR04 在桶蓋結露環境三個月就癌末
水溫感測DS18B20 防水不鏽鋼探針1~45 元三線版(VDD/DQ/GND),不要買兩線寄生供電版——深睡斷電時會讀出 85°C 鬼碼
電源閘2N7002 NMOS+100k/10kΩ1 組~5 元超聲波模組的 GND 開關;省電架構的本體
儲能18650+保護板1~85 元帶板版;陽台冬天低溫選容量衰減小的廠牌
發電5V 1W 太陽能板1~70 元本機平均電流預算 <0.15mA,1W 是給「連續陰雨+蚊蟲推播頻繁」的保險
充電TP4056+SS54 防逆二極體1~20 元RPROG 改 2kΩ 限充 0.5C 內
升壓MT3608(調 5.0V)1~10 元先調好再鎖膠,輸出飄壓會毒死 WS 元件與 ADC 基準
外殼防水接線盒 12×8×61~35 元IP65;裝桶蓋側面,探頭穿孔朝下
呼吸透氣膜+防水膠條1 組~25 元桶上空間日夜溫差大,不透氣=盒內下雨
人機按鍵1~5 元按下=立即回報(不用等 21:00)
雜項100kΩ×2、4.7kΩ、10kΩ、螺絲線材1 組~45 元100k 分壓測電池;4.7k 是 DS18B20 單線上拉(少了它是最常見故障)
合計~540 元無訂閱、無雲端;水桶資料全留自家

🔌 接線圖

C2 DevKitM-1 ESP8684 · 2MB GPIO4 TRIG GPIO5 ECHO GPIO3 電源閘 GPIO6 1-Wire GPIO0 VBAT 3V3/GND GPIO2 BTN 5V ⚠ C2 的 ADC1 只有 GPIO0–4:電壓偵測別搶數位的腳 JSN-SR04T 探頭朝下 死區 18cm:探頭離水面需 >18cm TRIG=4/ECHO=5 2N7002 電源閘 GATE←GPIO3(10k) 模組GND→D、S→板GND 閘極 100k 下拉 DS18B20 防水探針 DQ←4.7kΩ 上拉 3.3V(常電!) VDD→3.3V 常電,不過電源閘 1-Wire=GPIO6 按鍵→GND 18650(保護板) 100k/100k 分壓中點→GPIO0 電源鏈 太陽能 5V → TP4056+SS54 → 18650 → MT3608 5.0V → 板 5V 腳 VBUS 不接!升壓先鎖 5.0V 再進盒 5V
圖一:整機接線。綠色的 2N7002 電源閘是本篇的灵魂——超聲波模組的存活與死亡由 GPIO3 一手決定。
JSN-SR04T 模組 ESP32-C2-DevKitM1 備註 ───────────── ───────────────── ───────────── TRIG ────────────── GPIO4 ECHO ────────────── GPIO5 模組無供电時 ECHO 加 100k 下拉防浮動 VCC ────────────── 3.3V GND ──┐ │ (經 2N7002 D-S) 電源閘:GPIO3 HIGH=模組通電 2N7002 GATE ──10k── GPIO3 100kΩ G-S 下拉:MCU 重設期間保持斷 2N7002 S ───────── GND DS18B20 探針 ESP32-C2 ───────────── ───────────────── DQ ──4.7kΩ→3.3V── GPIO6 上拉必接;3.3V 走「常電」不要過電源閘 VDD ────────────── 3.3V(常電) GND ────────────── GND 人機/量測 ESP32-C2 ───────────── ───────────────── BTN ──── 內部上拉 ── GPIO2 按下=GND=立即回報 VBAT ──100k─┬─100k─ GND └────── GPIO0 ADC1_CH0 分壓中點(⚠ ADC1 只有 GPIO0–4) 電源鏈(全在盒內,只有太陽能板在外面): 太陽能 5V → TP4056(RPROG=2k) + SS54 防逆 → 18650+保護板 → MT3608 升壓 5.0V → 板 5V 腳。VBUS 絕不接。
⚠️ 三條軍規級小提醒:① DS18B20 的 4.7kΩ 上拉電阻不是建議是必要——沒有它你只會讀到 CRC 失敗 或永遠的 85.0°C(上電預設值鬼碼)。② 超聲波探頭朝下安裝、穿孔處做滴水簷:探頭膜面朝上會積雨珠,然後你就得到一個永遠顯示「還有半桶水」的騙子。③ 本機全低壓(5V),但 18650 是能量密度很高的東西——保護板、防逆二極體、絕熱墊一個都不能省,電池倉不要用黑色 ASA 直晒太陽。

⚙️ 3D 列印

零件材質列印設定設計重點
桶蓋感測器座PETG(陰面)/ASA(日照面)0.2mm、3 壁、防水螺旋頂探頭筆直朝下、座體邊緣做滴水簷;探頭到空桶水面的距離要 >18cm 死區——桶蓋太平就把座加高
防雨罩+透氣圈同上0.2mm、2 壁罩住盒體與探頭接線,底部透氣槽朝下;桶上不密閉設計,出水時空氣要進得去(真空會卡水)
太陽能板夾座ASA0.24mm、3 壁夾欄桿式,30° 仰角朝北(陽台直射位);走線槽貼夾臂下行
探頭固定套TPU(軟膠)0.2mm、100% 填充邊軟套減振:桶蓋被風吹得咚咚響時,震動噪音會混進回波——軟性緩衝是最便宜的濾波

總列印約 2.5 小時。桶身不用列印——賣場 190 公升儲水桶、或機車行免費要的舊油桶(洗淨)都行,打孔時開孔器比探頭直徑大 1mm、硅膠圈密封。

💻 Arduino 韌體

板子選 ESP32C2 Dev Module;Flash 2MB、Partition 選 Minimal(app 約 950KB,TLS+WiFi+NVS,關 OTA——2MB 的誠實之選)。DS18B20 用 OneWire 庫+自建 CRC8 解碼,不塞 DallasTemperature 這整頭大象進 2MB。

/* ============================================================
 * rain_barrel_gauge.ino — ESP32-C2 陽台雨水智慧量表(ESP32 全系列 × 3D 列印 第 25 篇)
 *
 * 功能:JSN-SR04T 測桶內水位(7 次中位數)+ DS18B20 水溫 →
 *       水位/存水天數/蚊蟲風險 → 低水位・感測失鎖・蚊幼蟲倒數三類告警
 *       + 每日 21:00 Telegram 日報;深睡 30 分鐘一醒、感測器電源閘、
 *       只在回報時開 Wi-Fi+TLS
 * 硬體:ESP32-C2-DevKitM-1(2MB)
 *       TRIG=GPIO4|ECHO=GPIO5|電源閘=GPIO3|1-Wire=GPIO6
 *       按鍵=GPIO2|電池 ADC=GPIO0(ADC1 僅 GPIO0–4,選腳已卡位)
 * 供電:太陽能 → TP4056+SS54 → 18650 → MT3608 5V → 板 5V(VBUS 不接)
 * 相依:Arduino ESP32 core ≥ 3.1(板選 ESP32C2 Dev Module)
 *       OneWire(paulstoffregen);mbedTLS 走 core 內建 WiFiClientSecure
 * ============================================================ */
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <OneWire.h>
#include <driver/gpio.h>
#include <nvs.h>
#include <esp_sleep.h>
#include <time.h>

/* --- 0. 使用者設定 --- */
const char* WIFI_SSID = "你家分享器";
const char* WIFI_PASS = "密碼";
const char* TG_TOKEN  = "000000:換成你的_bot_token";   // @BotFather
const char* TG_CHAT   = "換成你的_chat_id";

/* --- 1. 桶身幾何(換桶必改這三行)--- */
const float MOUNT_H   = 55.0f;  // cm:探頭到「空桶底」的距離=桶高+蓋高
const float BARREL_H  = 45.0f;  // cm:有效水深上限
const float AREA_CM2  = 2400.0f;// cm²:桶截面積(60×40 長方桶=2400)
const float DEAD_ZONE = 18.0f;  // cm:JSN-SR04T 近端死區,小於它視為失鎖
/* 每 1cm 水位=2.4L */

/* --- 2. 腳位 --- */
#define PIN_TRIG  4
#define PIN_ECHO  5
#define PIN_GATE  3    // HIGH=超聲波模組通電(2N7002 閘極)
#define PIN_1W    6    // DS18B20 單線(4.7k 上拉 3.3V)
#define PIN_BTN   2    // 內部上拉;按下=立即回報
#define PIN_VBAT  0    // ADC1_CH0:100k×2 分壓(⚠ C2 ADC1 只有 GPIO0–4)

/* --- 3. 策略參數 --- */
const uint32_t SLEEP_MS   = 30UL * 60UL * 1000UL;  // 例行:每 30 分鐘醒一次
const uint8_t  SAMPLE_N   = 7;    // 每次醒來量 7 筆取中位數(抗水面波浪)
const float    LOW_PCT    = 20.0f;// 低於 20% 推「補水」
const float    REFILL_CM  = 1.0f; // 水位上升 >1cm =補水事件(下雨或人工加水)
const uint8_t  MOQUITO_D  = 3;    // 存水超過 3 天
const float    MOQUITO_T  = 20.0f;// 且水溫超過 20°C → 蚊幼蟲倒數

OneWire ow(PIN_1W);

/* --- 4. 深睡也活下來的狀態(RTC slow mem)--- */
RTC_DATA_ATTR float  lastDepth = -1;      // 上次水深(-1=開機首樣本)
RTC_DATA_ATTR uint32_t refillDay = 0;     // 上次補水日(epoch 天)
RTC_DATA_ATTR uint32_t lowSentDay = 0;    // 低水位警報去重:今天已發過?
RTC_DATA_ATTR uint32_t mosqSentDay = 0;   // 蚊蟲警報去重
RTC_DATA_ATTR uint32_t lostSentDay = 0;   // 感測失鎖去重
RTC_DATA_ATTR uint32_t reportDay = 0;     // 21:00 日報去重(跨日鍵)

/* ============================================================
 * 電源閘 + 超聲波測距
 * ============================================================ */

/**
 * 開/關超聲波模組電源(2N7002 電源閘)。
 * 參數:on=true 通電
 * 為什麼:JSN-SR04T 靜態電流 ~3mA,比整台 ESP32 深睡還兇 600 倍——
 *         省電架構的本體就是這個開關,不是深睡本身。
 */
void sensorPower(bool on) {
  digitalWrite(PIN_GATE, on ? HIGH : LOW);
  delay(on ? 300 : 5);        // 模組上電需約 300ms 穩定(datasheet 建議值)
}

/**
 * 單次測距。
 * 回傳:探頭到水面距離(cm);0=本次無回波
 * 說明:TRIG 10µs 脈衝+ECHO 高電位 ×340m/s÷2;
 *       13500µs 上限=2.25m,桶內絕對夠。pulseIn 逾時回 0,原樣丟給上層當失鎖訊號。
 */
float pingCm() {
  digitalWrite(PIN_TRIG, LOW);
  delayMicroseconds(5);
  digitalWrite(PIN_TRIG, HIGH);
  delayMicroseconds(10);
  digitalWrite(PIN_TRIG, LOW);
  uint32_t us = pulseIn(PIN_ECHO, HIGH, 13500);
  return us * 0.0343f / 2.0f;   // µs → cm(聲速除以來回)
}

/**
 * 量一輪水位。
 * 參數:n=取樣次數(建議 7)
 * 回傳:距離中位數(cm);-1=感測失鎖(多數樣本為 0 或落入死區)
 * 中位數而非平均:大雨時水面是波浪場,偶發的早回波/無回波會把平均數拉歪,
 * 中位數天然免疫。
 */
float medianPing(uint8_t n) {
  float v[9]; uint8_t good = 0;
  for (uint8_t i = 0; i < n && i < 9; i++) {
    float d = pingCm();
    if (d > DEAD_ZONE && d < MOUNT_H + 15.0f) v[good++] = d;  // 死區與超量程都丟掉
    delay(60);
  }
  if (good < n / 2 + 1) return -1;              // 過半失敗=失鎖(探頭髒了/結藻)
  // 泡沫排序 7 個數:為了 7 個數引入 qsort 是對 2MB Flash 的挑釁
  for (uint8_t i = 0; i < good - 1; i++)
    for (uint8_t j = 0; j < good - 1 - i; j++)
      if (v[j] > v[j + 1]) { float t = v[j]; v[j] = v[j + 1]; v[j] = t; }
  return v[good / 2];
}

/* ============================================================
 * DS18B20 水溫(單線+自建 CRC8,不帶大庫進 2MB)
 * ============================================================ */

/**
 * CRC8(Maxim 單線多項式 x^8+x^5+x^4+1,反射型)。
 * 參數:dat=暫存器內容陣列、n=長度
 * 回傳:true=CRC 正確
 * 為什麼:單線總線在潮濕環境位翻轉很常見,沒 CRC 你會把雜訊讀成水溫。
 */
bool crc8ok(const uint8_t* dat, uint8_t n) {
  uint8_t crc = 0;
  for (uint8_t i = 0; i < n; i++) {
    uint8_t b = dat[i];
    for (uint8_t r = 0; r < 8; r++) {
      uint8_t mix = (crc ^ b) & 0x01;
      crc >>= 1;
      if (mix) crc ^= 0x8C;
      b >>= 1;
    }
  }
  return crc == 0;   // 含 CRC byte 在內整包同餘 0
}

/**
 * 讀水溫。
 * 回傳:攝氏溫度;-999=總線無回應/CRC 失敗
 * 流程:Skip ROM→ConvertT(12bit, 750ms)→再 Skip→讀 9-byte scratchpad。
 *       兩段之間不睡死:12-bit 轉換要 750ms,用 delay 換程式可讀性,
 *       本機每個 wake 週期只讀一次,遲得起。
 */
float readWaterTemp() {
  uint8_t sp[9];
  ow.reset();
  ow.skip();
  ow.write(0x44, 1);                 // Convert T, 外部供電無需 parasite power
  delay(760);
  ow.reset();
  ow.skip();
  ow.write(0xBE, 0);                 // Read Scratchpad
  for (uint8_t i = 0; i < 9; i++) sp[i] = ow.read();
  if (!crc8ok(sp, 9)) return -999.0f;
  int16_t raw = (int16_t)((sp[1] << 8) | sp[0]);
  if (raw == 0x0750) return -999.0f; // 上電預設 85.0°C 鬼碼(剛上電沒轉換過)
  return raw * 0.0625f;
}

/* ============================================================
 * Telegram(只在必須說話時開 Wi-Fi)
 * ============================================================ */

/**
 * 發送 Telegram。
 * 參數:msg=內文
 * 回傳:true=送達
 * 逾時重試 3 次、指數退避;setInsecure 見第 24 篇安全性框——
 * 正式部署改 setCACert 或自家伺服器中轉;token 權限只開「發訊息」。
 */
bool sendTg(String msg) {
  if (WiFi.status() != WL_CONNECTED) return false;
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.begin(client, String("https://api.telegram.org/bot") + TG_TOKEN + "/sendMessage");
  http.addHeader("Content-Type", "application/json");
  for (int t = 0; t < 3; t++) {
    String body = "{\"chat_id\":\"" + String(TG_CHAT) + "\",\"text\":\"" + msg + "\"}";
    int code = http.POST(body);
    if (code == 200) { http.end(); return true; }
    delay(500 << t);
  }
  http.end();
  return false;
}

/** 連網→校時→發訊息→射頻關光光。回傳:是否送出 */
bool talk(String msg) {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  bool sent = false;
  if (WiFi.waitForConnectResult() == WL_CONNECTED) {
    configTime(8 * 3600, 0, "pool.ntp.org");   // 醒著才校時,深睡靠 RTC 走
    getLocalTime(nullptr, 2000);               // 把系統時鐘拉起來給 time() 用
    sent = sendTg(msg);
    WiFi.disconnect(true);
    WiFi.mode(WIFI_OFF);                        // 射頻是省電大頭,用完關
  }
  return sent;
}

/* ============================================================
 * 主邏輯
 * ============================================================ */
float vbat() { return analogReadMilliVolts(PIN_VBAT) * 2.0f / 1000.0f; }

void setup() {
  Serial.begin(115200);
  pinMode(PIN_GATE, OUTPUT); digitalWrite(PIN_GATE, LOW);
  pinMode(PIN_TRIG, OUTPUT); digitalWrite(PIN_TRIG, LOW);
  pinMode(PIN_ECHO, INPUT);
  pinMode(PIN_BTN, INPUT_PULLUP);
  // ECHO 在模組斷電時是浮動的——先拉低幾輪,防止深睡喚醒殘留脈衝誤判
  for (uint8_t i = 0; i < 3; i++) { digitalWrite(PIN_TRIG, HIGH); delay(1);
                                    digitalWrite(PIN_TRIG, LOW); delay(1); }
  Serial.println(F("\n=== rain_barrel_gauge 雨水量表啟動 ==="));
}

void loop() {
  uint32_t today = (uint32_t)(time(nullptr) / 86400);   // epoch 天
  bool btnNow = digitalRead(PIN_BTN) == LOW;
  bool first = (lastDepth < 0);

  /* --- 1. 量水位(電源閘開→中位數→關)--- */
  sensorPower(true);
  float dist = medianPing(SAMPLE_N);
  sensorPower(false);
  float depth = constrain(MOUNT_H - dist, 0.0f, BARREL_H);
  float pct = depth / BARREL_H * 100.0f;
  float litres = depth * AREA_CM2 / 1000.0f;            // cm³→L
  Serial.printf("[level] dist=%.1fcm depth=%.1fcm %.0f%% %.0fL\n",
                dist, depth, pct, litres);

  /* --- 2. 補水事件偵測:水位明顯上升=下雨或人工加水 → 重置存水計時 --- */
  if (!first && depth > lastDepth + REFILL_CM) {
    refillDay = today;
    Serial.println(F("[refill] 補水事件:存水計時器歸零"));
  }
  lastDepth = depth;

  /* --- 3. 量水溫(探針常電,不需要電源閘)--- */
  float tempC = readWaterTemp();
  uint32_t ageD = (today >= refillDay && refillDay > 0) ? (today - refillDay) : 0;

  /* --- 4. 組裝訊息:三類告警各自去重(同一天同一類只轟一次)--- */
  String msg;
  bool urgent = false;
  if (dist < 0 && lostSentDay != today) {
    lostSentDay = today; urgent = true;
    msg = "🧽 雨水量表:超聲波回讀失鎖——探頭可能結藻/沾水珠,請擦一下探頭。在那之前「水位」欄位是玄學。";
  } else if (pct < LOW_PCT && lowSentDay != today) {
    lowSentDay = today; urgent = true;
    msg = "🚱 省水桶見底:" + String((int)pct) + "%(剩 " + String((int)litres)
        + "L)。要澆水的話,今天就開水龍頭吧,別讓花在等。";
  } else if (ageD >= MOQUITO_D && tempC >= MOQUITO_T && mosqSentDay != today) {
    mosqSentDay = today; urgent = true;
    msg = "🦟 蚊幼蟲倒數:桶內水已 " + String(ageD) + " 天沒換、水溫 "
        + String(tempC, 1) + "°C——斑蚊的育嬰房正在量產中。澆完就把它用掉,或整桶換水。";
  }

  /* --- 5. 每日 21:00 日報(跨日鍵去重)--- */
  struct tm t;
  bool clockOK = getLocalTime(&t, 500);
  bool daily = clockOK && t.tm_hour == 21 && reportDay != today;

  /* --- 6. 要說話才連網(連網=本機最貴的動作)--- */
  bool btnPress = btnNow;
  if (btnPress) { while (digitalRead(PIN_BTN) == LOW) delay(10); }  // 放鍵防連發
  if (urgent || daily || btnPress) {
    String rep = "💧 陽台省水桶:" + String((int)litres) + "L(" + String((int)pct)
               + "%)· 水溫 " + (tempC > -500 ? String(tempC, 1) + "°C" : "讀不到")
               + " · 存水 " + String(ageD) + " 天 · 電池 " + String(vbat(), 2) + "V";
    bool ok = talk(msg.length() ? (msg + "\n" + rep) : rep);
    Serial.printf("[tg] %s\n", ok ? "已送達" : "發送失敗(下次喚醒補發)");
    if (daily) reportDay = today;
  }

  /* --- 7. 回家睡覺(30 分鐘;電壓過低拉長到 2 小時保命)--- */
  Serial.flush(); delay(10);
  esp_sleep_enable_timer_wakeup((vbat() < 3.40f ? 2UL * 3600UL : 1800UL) * 1000000ULL);
  esp_deep_sleep_start();
}
📌 2MB Flash 的生存術:Partition 選 Minimal(app 1.9MB+NVS+剩餘 storage),單一 app、OTA 關閉。編譯選項把 CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN 保持預設 16KB(縮到 4KB 會和 Telegram 的伺服器握手中斷——實測過,別問)。省體積的另一招是上面代碼裡「OneWire 手解 DS18B20」:DallasTemperature 庫連同字串格式化要吃 20 多 KB,對 2MB 而言是大象。
🔐 setInsecure 的老實話:連 Telegram 官方 API 用 setInsecure() 是社群常例,但它是「不驗證憑證」——bot token 會經過一條可能被中間人的路。正式做法:抓 CA 後 client.setCACert(ca_pem),或推播改走自家伺服器中轉。token 權限只開「發訊息」。

🔧 組裝步驟

  1. 桌面通電測試:先不裝電源閘(GPIO3 直接接 3.3V 旁通),跑 Serial 看 ping 正常後,再接回 MOSFET——先分清「感測器壞」和「電源閘壞」兩種病。
  2. 電源閘驗證:GPIO3 拉低後量模組 VCC 應 <0.1V、整機待機電流 <0.2mA;拉高後 300ms 內可量到回波。待機電流超標先查 LED 與上拉電阻漏電。
  3. 水位校準:長尺量實際水深對 Serial 讀值,誤差 >1.5cm → 查探頭是否垂直水面、桶蓋是否傾斜。改 MOUNT_H 前先懷疑物理安裝。
  4. 蚊蟲模式演練:Serial 指令把 refillDay 灌成 5 天前(或直接把水溫探針含在手心加熱到 25°C)→ 推播觸發=良心模組上崗。
  5. 入盒與防水:電路進盒、探頭穿孔打硅膠、透氣膜貼最低點對角;線材出口做防滴彎。盒蓋鎖緊前再量一次水位(開蓋測過=沒測過)。
  6. 上架:桶蓋側面固定盒體、探頭懸在桶內中央(離桶壁 >15cm 防壁反射);太陽能板夾欄桿朝北 30°;按鍵拉到桶蓋正面。澆花壺沖淋 3 分鐘無滲水=正式上崗。
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到陽台 → 開啟 HTML 組裝說明書

🧪 驗證

測試動作合格線
死區驗證探頭下方 15cm 放平板讀值必落入「失鎖」邏輯而不是亂讀小值
水位校準長尺實測水深 vs Serial誤差 ≤1.5cm;連續 10 次讀值波動 ≤1cm(無波浪時)
斷閘驗證電流串入電池線,GPIO3 拉低整機待機 <0.2mA;模組 VCC <0.1V
蚊蟲模式灌假 refillDay=5 天前+水溫 >20°CTelegram 收到蚊幼蟲倒數,且同一天不重複轟炸
失鎖自白探頭貼保鮮膜模擬結藻觸發「擦探頭」推播,不是靜靜報假水位
防水澆花壺沖淋桶蓋 3 分鐘盒內乾燥、水位讀值不變
補水重置人工加 5L 水下個循環「存水 0 天」,蚊蟲倒數歸零

🆘 問題排除

症狀最可能的原因處方
讀值恆 0 或恆最大TRIG/ECHO 接反;電源閘沒開(模組沒電);探頭膜面積水珠核對兩根訊號線;量模組 VCC;探頭朝下+滴水簷重裝
水位跳動 ±3cm大雨波浪(正常);桶內立柱/水管回音;探頭離壁太近中位數已是解方;移動探頭至桶面中央、離壁 >15cm
DS18B20 讀 85.0 或 -9994.7k 上拉漏接;VDD 接到電源閘後面;探針進水補上拉;VDD 改常電 3.3V;探針要買灌封不鏽鋼版
深睡醒來第一筆怪值喚醒瞬間 ECHO 殘留高位setup 先拉低 TRIG 數輪(程式已做);第一筆樣本丟掉可再加保險
日報沒發21:00 判斷写在未連網分支;RTC 走時漂移日報判斷放連網成功後(程式已做);每次連網重 configTime
待機電流 1mA 起跳TP4056 反接漏電;升壓未鎖膠飄壓;按鍵一直壓著逐級拔模組定位;升壓重調 5.0V;查按鍵卡死
冬天讀值縮水鋰電池低溫內阻上升,電壓偵測誤判低壓降頻正常保護行為;桶身包泡棉保电池也算保温

📁 專案檔案結構

rain-barrel-gauge/
├── firmware/
│   └── rain_barrel_gauge.ino     # 上方完整韌體
├── stl/
│   ├── lid-sensor-mount.stl      # 桶蓋感測器座(含滴水簷)
│   ├── rain-shroud.stl           # 防雨罩+透氣圈
│   ├── solar-clip.stl            # 太陽能板夾座
│   └── probe-bushing.stl         # TPU 探頭軟套
├── f3d/
│   └── rain-barrel.f3d           # Fusion 360 原始檔
├── manual/
│   └── assembly.html             # 圖文組裝說明書
└── README.md                     # 說明文件

🎯 結語

這個專案最值錢的不是那顆 75 元的晶片,是「把遺忘外包出去」這個架構決定:水位計不會讓你變成更好的園丁,它只是把你忘記的那件事——桶裡還有沒有水、水放了幾天、蚊子開不開班——在正確的時間推到你手机上。省水帳要算一年才好看,但蚊子帳當天就回本:存水第 3 天那則「蚊幼蟲倒數中」的推播,是這台機器替你家陽台做的最好的一次公共衛生。而當水費級距下次跳級、你看著 App 裡自己給的「這個月省了 11 桶」的日報時,會想起這句話:最好的感測器,是幫你記得起的那一個。

← 上一篇:ESP32-C6 雷暴預警基地台 📚 系列索引 下一篇:ESP32-C6 陽台風速風向塔 →