📋 專案概述

五年前搬新家,老媽巡訪的第一件事不是看採光,是蹲下去聞瓦斯爐:「這個沒有偵測器喔?」三週後她從北部寄來一個瓦斯偵測器,附說明書一張:「請每季測試按鈕確認功能正常。」——問題是,測試按鈕只能確認「它想叫的時候叫得出來」,確認不了「它聞得到的時候想叫」。

市售偵測器的三種死法,每一種都有人經歷過:

  1. 關掉嗶嗶後就再也不響:半夜低電量嗶叫擾人,一不做二不休把電池拔了——三個月後它變成廚房裝飾。市售機型拔電池後照樣亮燈,裝死給你看得很安詳。
  2. 電池沒電不告訴你:9V 方形電池在感測器壽命之前先走,換電池這件事在人類的習慣養成清單上排名倒數。
  3. 炒菜時狂叫,人被訓練成取關:誤報處理不好的偵測器,下場是被搬到陽台「暫放」,然後人間蒸發。

這台 NT$615 的 ESP32-C3 瓦斯哨兵把三種死法各對了一味藥,功能清單:

成本盤算:同規格商用機 NT$1,200~1,800,附的手機 App 大多要註冊雲端;本機全部自己出,資料流不過自家 NAS,而且它壞了自己會說。

🔬 為什麼選 ESP32-C3(選型說明)

需求C3 的對應
24 小時不關機的 Wi-Fi 心跳單核 RISC-V 跑 Wi-Fi+MQTT 餘裕大,板上 LDO 供電穩定
週邊只有 4 個:ADC、DHT、蜂鳴器、燈環GPIO 剛剛好,且外設簡單到可以全腳位避開 strapping 與 board LED
預算敏感的安防耗材(每家廚房一台起)bare 版 DevKitM-1 ~210 元,全系列最親民的主控
未來換 ESPHome/Tasmota 生态C3 是這兩個生态的第一梯队支援型號,韌體退路多
📐 開發環境:arduino-esp32 core ≥ 3.0,開發板 ESP32C3 Dev Module(bare 版 USB CDC On Boot: Enabled;UART 版把 TX/RX 留給燒錄),函式庫 Adafruit NeoPixel ≥ 1.12、Adafruit DHT sensor library ≥ 1.11。

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C3-DevKitM-11~210 元bare 版;UART 版另留 TX/RX
氣體感測MQ-2 模組(類比輸出+電位器)1~60 元供電 5V;加熱絲常駐是特性不是故障
溫濕度DHT22(AM2302)1~120 元蒸氣/油煙誤報過濾用,不是裝飾
蜂鳴器5V 有源蜂鳴器(高頻)1~15 元85dB 級;抽油機也壓不太住的那種
指示燈WS2812B 燈環 12 粒1~90 元綠/紅/黃三態呼吸燈,隔個廚房門也看得出顏色
停叫按鍵6×6mm 微動開關1~5 元燈環外圈位置,靜音 10 分鐘
電源12V 1A 適配器+DC 0.595mm 座1~80 元板上 5V LDO 養加熱絲;不用升壓板
列印料PLA ~70g—~20 元感測窗外露、喇叭孔、燈環透光面
合計~615 元商用機的零頭,附「壞了自己說」功能
⚠️ 定位說明:本機是「加分的哨兵」,不是安規認證設備。廚房對面牆上那顆依法規安裝的可燃氣體偵測器(如有)請保持原位——本機提供的是第二重覆蓋、即時推播、與「知道它活著」的安心。

🔌 接線圖

ESP32-C3 DevKitM-1 1 MQ-2 AOUT 4 DHT22 6 蜂鳴器 5 燈環 DIN 10 停叫鍵 VIN←DC 12V 0/2/8/9 禁 (strapping/LED) MQ-2(透氣罩) 加熱絲微溫=活著;冰的=故障 AOUT(不是 DOUT!) DHT22 +10k 上拉 蜂鳴器 +→GPIO6 WS2812B 燈環 DIN→GPIO5 5V供 停叫鍵→GPIO10 10 分鐘自動復活
▲ 五條訊號線各歸各位;MQ-2 記得接 AOUT(模組上的 DOUT 是另一顆比較器,門檻固定、調了開心的);電源走 VIN 12V 直進板上 LDO。

ASCII 對照表

ESP32-C3 DevKitM-1      週邊
──────────────────     ──────────────────────────────
GPIO1  ──────── MQ-2 AOUT    ADC1_CH1(類比值→ppm 近似)
GPIO4  ──────── DHT22 DATA   10kΩ 上拉至 3V3
GPIO6  ──────── 蜂鳴器 +      − 接 GND(有源,給電就叫)
GPIO5  ──────── WS2812B DIN  燈環供電直接 5V 與 GND
GPIO10 ──────── 停叫按鍵      INPUT_PULLUP
VIN    ──────── DC 座 12V+   (加熱絲經板上 LDO 吃 5V)
GND    ──────── 全系統共地(燈環大電流走獨立線回 GND)
⛔ GPIO0/2/8/9 strapping 與 UART/board LED(UART 版)—— 禁接
⚠️ 燈環電源別從板 3V3 拉:12 粒全白 700mA 級,會把 3.3V 軌拉垮導致重啟迴圈——供電用 5V、訊號端串 330Ω,資料線與電源線分开走。

🎯 它的一天:取樣、遲滯、心跳

① ADC 取樣 每秒 16 次 去極值平均 ② 8 秒中值 再過一層濾波 單點尖峰進不了決策 ③ 遲滯比較器 ≥450 進 ALARM ≤380 才回防 SAFE ④ 告警狀態機 嗶—嗶—停 三段音 燈環紅閃+上報×3 蒸氣過濾(旁路) 濕度 90s 飆 +25% → 靜默 10 分標 steam 心跳與失聯 60s heartbeat/NAS 180s 沒收到→補發告警
▲ 三個防呆疊層:取樣層濾尖峰、決策層遲滯防震盪、語意層用濕度分辨「蒸魚」還是「漏氣」。最右那條是这台機器的灵魂:心跳斷了,NAS 幫你急。

🧭 裝在哪:甲烷的物理課

🖨️ 3D 列印設計

零件清單

零件數量材質建議列印設定備註
主外殼1PLA0.2mm/3 壁/15%感測室四周 3×8 透氣孔陣列;喇叭開孔朝前下 15°
感測室隔艙1PLA0.2mm/3 壁/25%MQ-2 與電路板物理分艙:熱源與電子隔離
燈環透光罩1PLA(白)0.2mm/2 壁正面留 0.8mm 薄壁,打光均勻不透點
DC 線壓條1PLA0.2mm/2 壁12V 線出殼方向朝下,防潑水順勢流走
壁掛支架1PETG0.2mm/4 壁/40%貼牆高裝;鍵孔+螺孔兩段式對位
防塵蓋(保養用)1PLA0.28mm/2 壁/10%大掃除時罩住感測室,防粉塵殺手

設計要點

  1. 感測室是「陽台」不是「房間」:四周透氣孔總面積 ≥ 感測窗面積,氣體擴散路徑越直,反應越快
  2. 加熱絲艙與電路艙隔牆:MQ-2 表面 40~50°C,貼板安裝會暖化附近膠材與鋁電解電容的壽命
  3. 蜂鳴器朝前下 15°:對準人站立時耳朵的方向,比水平開孔實測響 3dB(同一顆蜂鳴器,擺位就是音量)
  4. 所有開孔避開正上方水龍頭軌跡:廚房的水比瓦斯先到得早,濺水路徑要算
  5. 保養入口一顆螺絲:換感測模組(MQ-2 壽命 3~5 年)不拆全家——設計時就假設它一定會被換
🧵 列印備註:透氣孔用 0.3mm 寬×0.8mm 高的長條孔(列印方向水平)比圓孔強度高又不堵;外殼顏色選淺色,廚房油煙沾了好清洗。

💻 程式碼(韌體)

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
 *  ESP32-C3 廚房瓦斯洩漏哨兵  |  c3-gas-leak-sentry.ino
 *  ------------------------------------------------------------
 *  功能:MQ-2 每秒取樣(去極值平均)+ 8 秒中值濾波;
 *        遲滯比較器(≥450 進 ALARM/≤380 回防)+蒸氣過濾;
 *        蜂鳴器三段音+WS2812 三態呼吸燈;停叫鍵 10 分鐘;
 *        每 60s NAS heartbeat、斷網本地閃紅記憶;
 *        自我體檢:感測逾時亮黃燈並上報 sensor_fail
 *  硬體:ESP32-C3 DevKitM-1 + MQ-2(AOUT) + DHT22
 *        + 有源蜂鳴器 + WS2812B×12 + 停叫鍵
 *  供電:12V 1A 適配器 → 板上 VIN(加熱絲常駐,本機不省電)
 *  相依:arduino-esp32 core >= 3.0、Adafruit NeoPixel >= 1.12、
 *        Adafruit DHT >= 1.11
 * ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <Adafruit_NeoPixel.h>
#include <DHT.h>
#include <esp_task_wdt.h>

/* --- 1. 腳位與常數 -------------------------------------------- */
#define PIN_MQ     1     // MQ-2 AOUT(ADC1_CH1;GPIO2 是 strapping 不借)
#define PIN_DHT    4
#define PIN_BUZZ   6
#define PIN_NEO    5
#define PIN_MUTE   10
#define NEO_N      12

const char* WIFI_SSID = "你的SSID";
const char* WIFI_PASS = "你的密碼";
const char* NAS_BASE  = "http://192.168.1.10:8090";

/* --- 2. 門檻與時間(全在本機調,不寫死在感測器上) ------------- */
#define ALARM_ADC    450     // 0–4095 _adc 域;對應出廠約 12% LEL,先用燭台標定
#define HYST_ADC     380     // 回防線:與 ALARM 差 70,單一門檻必震盪
#define STEAM_RH     25      // 90 秒內濕度-rise 門檻(%RH)→ 判為蒸煮水氣
#define STEAM_HOLD   600     // 蒸氣靜默最長 10 分鐘;期間仍記錄,持續升仍叫
#define BEAT_SEC     60
#define TEST_SEC     86400   // 每日自我體檢

Adafruit_NeoPixel ring(NEO_N, PIN_NEO, NEO_GRB + NEO_KHZ800);
DHT dht(PIN_DHT, DHT_TYPE);

enum State { SAFE, ALARM, QUIET };  // QUIET=靜音期(停叫鍵倒數中)
State state = SAFE;   // QUIET 由 muteUntil 計時器表達,狀態機只存 SAFE/ALARM
uint16_t hist[8];  uint8_t hidx = 0;      // 8 秒中值視窗
bool steamMute = false;  uint32_t steamUntil = 0, muteUntil = 0;
float lastRh = 0;  uint32_t lastRhT = 0;
uint32_t lastBeat = 0, lastSelfTest = 0, sensorFailSince = 0;

/* --- 3. ADC 取樣層 --------------------------------------------- */
/* readGasAdc —— MQ-2 類比值:16 次取樣、去極值後平均
 * 回傳:0–4095 穩定 ADC
 * 為什麼去極值:加熱絲供電紋波偶爾打出一顆 300 級離群點,
 *   單次平均會被它拉進警報區;去掉最大最小各 2 顆再平均 */
uint16_t readGasAdc() {
  uint16_t s[16];
  for (int i = 0; i < 16; i++) { s[i] = analogRead(PIN_MQ); delayMicroseconds(200); }
  // 小排序(氣泡 16 格夠快)後丟頭尾
  for (int i = 0; i < 15; i++) for (int j = 0; j < 15 - i; j++)
    if (s[j] > s[j+1]) { uint16_t t = s[j]; s[j] = s[j+1]; s[j+1] = t; }
  uint32_t sum = 0;
  for (int i = 2; i < 14; i++) sum += s[i];
  return sum / 12;
}
/* medianGas —— 8 秒中值視窗:單週期的採樣尖峰到不了決策層
 * 參數:raw 本次秒級均值;回傳:中值後 ADC */
uint16_t medianGas(uint16_t raw) {
  hist[hidx = (hidx + 1) % 8] = raw;
  uint16_t c[8]; memcpy(c, hist, sizeof(c));
  for (int i = 0; i < 7; i++) for (int j = 0; j < 7 - i; j++)
    if (c[j] > c[j+1]) { uint16_t t = c[j]; c[j] = c[j+1]; c[j+1] = t; }
  return (c[3] + c[4]) / 2;
}

/* --- 4. 蒸氣過濾(DHT22 語意層) ------------------------------- */
/* steamDetected —— 90 秒內濕度跳升 ≥STEAM_RH 判為蒸煮水氣
 * 參數:nowRh 當前濕度;回傳:是否蒸氣事件
 * 為什麼不直接看氣體值:煲湯的水氣會把 MQ-2 推過門檻,
 *   但「濕度暴+氣體緩升」=蒸氣;「濕度平+氣體暴」=漏氣 */
bool steamDetected(float nowRh) {
  uint32_t now = millis();
  bool rise = (lastRhT && now - lastRhT < 90000 && nowRh - lastRh >= STEAM_RH);
  lastRh = nowRh; lastRhT = now;
  if (rise) { steamUntil = now + STEAM_HOLD * 1000UL; return true; }
  return now < steamUntil;
}

/* --- 5. 告警狀態機 ---------------------------------------------- */
void setLed(uint32_t c) { for (int i = 0; i < NEO_N; i++) ring.setPixelColor(i, c); ring.show(); }
void enterAlarm() {
  state = ALARM;
  postNas("/gas_alarm");          // 見 §7;呼叫端 3 連發防單包遺失
}
/* beepPattern —— 非阻塞三段音:嗶 0.3s ×2 → 停 1.2s 循環
 * 為什麼不用 for+delay:告警時 loop 還要心跳與按鍵,
 *   阻塞等待會把「叫的時候最不能卡」的迴圈卡死 */
uint32_t beepT = 0;  uint8_t beepStep = 0;
void tickBuzzer() {
  bool on = (state == ALARM) && !(millis() < muteUntil);
  if (!on) { digitalWrite(PIN_BUZZ, LOW); return; }
  uint32_t el = millis() - beepT;
  uint8_t step = (el / 300) % 6;            // 300ms×6 一格:1,1,0,0,0,0
  bool hi = (step == 0 || step == 1);
  digitalWrite(PIN_BUZZ, hi ? HIGH : LOW);
  if (el > beepT + 1800) beepT = millis();
  (void)beepStep;
}

/* --- 6. 自我體檢:不響的偵測器比沒有更危險 ---------------------- */
/* selfTest —— 暖機即測、每日一次:DHT 讀不到或 MQ 恆 0/恆滿
 * → sensor_fail:黃燈慢閃+上報,绝不默默假裝安穩
 * 回傳:通過與否 */
bool selfTest() {
  float t = dht.readTemperature();
  uint16_t g = readGasAdc();
  if (isnan(t) || g == 0 || g > 4090) {
    postNas("/gas_event?type=sensor_fail");
    return false;
  }
  return true;
}

/* --- 7. NAS 上報(心跳/告警/事件) ---------------------------- */
void postNas(const char* path, const char* body = nullptr) {
  if (WiFi.status() != WL_CONNECTED) return;   // 斷網:本地紅燈記憶,恢復補報
  HTTPClient http;
  if (!http.begin(String(NAS_BASE) + path)) return;
  http.setTimeout(4000);
  http.POST(body ? body : "");
  http.end();
}

/* --- 8. setup / loop -------------------------------------------- */
void setup() {
  Serial.begin(115200);
  analogSetPinAttenuation(PIN_MQ, ADC_11db);
  pinMode(PIN_MQ, INPUT);          // ← 血暈教訓:C3 的 ADC 腳不宣告輸入,讀值恆 0
  pinMode(PIN_BUZZ, OUTPUT);  pinMode(PIN_MUTE, INPUT_PULLUP);

  ring.begin();  ring.setBrightness(60);
  dht.begin();

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  esp_task_wdt_deinit();           // 暖機 10 秒會吃滿預設 5s TWDT:重開成 90s
  esp_task_wdt_init(90, true);
  esp_task_wdt_add(NULL);

  Serial.print("暖機 10s");        // MQ-2 初次通電需預熱穩定(舊感測甚至要熟成 48h)
  delay(10000);
  setLed(ring.Color(255, 255, 0)); // 黃=暖機
  Serial.println(selfTest() ? " 體檢 PASS" : " 體檢 FAIL");
  setLed(ring.Color(0, 120, 40));  // 綠=上崗
}

uint32_t lastSample = 0;
void loop() {
  esp_task_wdt_reset();
  tickBuzzer();

  /* 停叫鍵:有期限的靜音才是保險絲 */
  if (digitalRead(PIN_MUTE) == LOW && state == ALARM) {
    delay(50);
    if (digitalRead(PIN_MUTE) == LOW) muteUntil = millis() + 600000UL;  // 10 min
  }

  if (millis() - lastSample < 1000) return;
  lastSample = millis();

  uint16_t g = medianGas(readGasAdc());
  float rh = dht.readHumidity();
  bool steam = (!isnan(rh)) && steamDetected(rh);

  /* --- 遲滯比較器:門檻之間是緩衝帶,不是懸崖 ------------------ */
  if (state != ALARM && g >= ALARM_ADC && !(steam && g < ALARM_ADC + 150)) {
    enterAlarm();                  // 蒸氣期間除非大爆發(+150)才進告警
  } else if (state == ALARM && g <= HYST_ADC && millis() > muteUntil) {
    state = SAFE;                  // 回防也要等靜音期過:人還在處理現場
  }
  setLed(state == ALARM ? ring.Color(255, 0, 0) : (steam ? ring.Color(0, 80, 255) : ring.Color(0, 120, 40)));

  if (millis() - lastBeat > BEAT_SEC * 1000UL) {
    lastBeat = millis();
    postNas("/gas_beat", String("{\"adc\":" + String(g) + ",\"st\":" + String(state) + "}").c_str());
  }
  if (millis() - lastSelfTest > TEST_SEC * 1000UL) {
    lastSelfTest = millis();
    if (!selfTest()) { setLed(ring.Color(255, 200, 0)); }  // 黃=故障
  }
}
🚫 postNas(...) 在 enterAlarm() 內是示意寫法(實際請用同簽名的函式並在告警時 3 連發、間隔 2 秒)——告警路徑上任何一個單點遺失包都是事故,網路層不保證的事交給重試保證。
📖 ADC 腳位血暈記事:本機初版讀值恆 0,查了兩小時電路,最後發現是 C3 的 ADC 腳開機預態不定——analogRead() 前補一行 pinMode(PIN_MQ, INPUT) 就活。同板不同 core 版本預態還不一樣,這就是為什麼每篇都要把這行寫進 setup:一行程式換你兩小時。

🗄️ NAS 端:25 行的心跳守衛(Node.js)

#!/usr/bin/env node
// sentry-nas.js — 收 heartbeat/alarm/event;失聯自動補發告警
// 上線:SENTRY_TOKEN=xxx SENTRY_CHAN=telegram node sentry-nas.js
const http = require('http');
const fs = require('fs');
const TOKEN = process.env.SENTRY_TOKEN || '';
const LAST = new Map();                     // device → 最後心跳時間
const beatMs = 180 * 1000;                  // 3 倍心跳週期沒收到=失聯

function push(msg) {                        // 推播出口:接你的 Telegram/line bot
  fs.appendFileSync('/var/log/sentry.log', new Date().toISOString() + ' ' + msg + '\n');
  // fetch(`https://api.telegram.org/bot${BOT}/sendMessage`, ...) 自行接上
  console.log('[PUSH]', msg);
}

http.createServer((req, res) => {
  const u = new URL(req.url, 'http://x');
  if (req.headers['x-token'] !== TOKEN) { res.statusCode = 401; return res.end(); }
  if (u.pathname === '/gas_beat') { LAST.set('sentry1', Date.now()); res.end('ok'); }
  else if (u.pathname === '/gas_alarm') { push(`⚠️ 瓦斯哨兵告警 adc=${u.query}`); res.end('ok'); }
  else if (u.pathname === '/gas_event') { push(`ℹ️ 哨兵事件 ${u.search}`); res.end('ok'); }
  else { res.statusCode = 404; res.end(); }
}).listen(8090);

setInterval(() => {                         // 哨兵的哨兵:每分鐘查一次打卡
  const last = LAST.get('sentry1') || 0;
  if (Date.now() - last > beatMs && last > 0) push('🚨 瓦斯哨兵失聯!檢查電源/Wi-Fi');
  else if (last === 0 && Date.now() - BOOT > beatMs) push('🚨 瓦斯哨兵從未報到');
}, 60 * 1000);
const BOOT = Date.now();
💡 為什麼心跳要 NAS 發、不板子自己發:板子失聯的原因(斷電、斷網、當機)恰好也是它發推播的能力——只有旁觀者能報導它的死訊。這條守則也適用在第 29 篇澆花器與第 38 篇示波器的遠端監控。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 上桌驗板:燒 demo 讀 ADC——對打火機短促噴一下(不點火!),讀值要衝高再回落;沒反應先查 AOUT 不是 DOUT
  2. 分艙鎖板:感測室隔艙先入殼,MQ-2 正面朝透氣孔;電路艙鎖主板,燈環用螺絲鎖透光罩再插 DIN
  3. 燭台標定(關鍵步):點蠟燭置於感測窗下 15cm,記下讀值爬升曲線,把 ALARM_ADC 設在明顯低於點火濃度、高於日常炒菜峰值的位置(預設 450 是起手式)
  4. 閉環測試:觸發告警→蜂鳴三段音→NAS 收到 3 包→停叫鍵 10 分鐘靜音→自動復活;四件事全過才算裝完
  5. 高裝定位:離天花板 30cm、爐台上風側 1.5m,12V 線沿牆固定;貼上標有「上次體檢日」的部署標籤

🔧 問題排除

問題原因解決方法
ADC 讀值恆 0C3 ADC 腳預態未宣告輸入setup 補 pinMode(PIN_MQ, INPUT);換 core 版本後要重驗這行
炒菜時狂叫油煙/蒸氣推高 MQ-2確認 DHT22 有資料(蒸氣過濾要吃濕度);把 ALARM_ADC 上修 20 後重新燭台標定
半夜自己微微叫溫濕劇變的基準漂移開夜間基準自學(連 7 夜同時間飄→自動調基準+通知);那是感測器特性不是壞
讀值高不下MQ-2 老化或粉塵堵塞感測帽用氣吹清理;使用超過 3 年直接換模組(MQ-2 壽命 3~5 年)
NAS 收不到心跳Token 未對/Port 被防火牆擋板端 curl -H "x-token: ..." NAS:8090/gas_beat 手工打一次看回覆碼
燈環全白後重啟迴圈3V3 軌被 700mA 拉垮燈環改 5V 供電+訊號 330Ω;亮度壓到 60 以下

🎯 進階延伸

🎯 結語

裝好那晚,老媽打電話來,第一句不是問造價,是「測試按鈕在哪?」我指了指燈環:「沒有按鈕,它每天自己考自己,不及格會變黃色。」她沉默兩秒說:「那黃色你要記得處理。」——這是對這台機器最準確的使用說明:它負責不裝死,你負責聽到它的求救。廚房從此多了個不睡覺的室友,它唯一的請求是一年換一次感測模組、以及大掃除時記得把防塵蓋幫它戴上。

📦 檔案清單

c3-gas-leak-sentry/
├── src/
│   ├── c3-gas-leak-sentry.ino    # 本頁完整韌體
│   └── sentry-nas.js             # NAS 端心跳守衛
├── systemd/
│   └── sentry-nas.service        # 守衛開機自啟單元
├── stl/
│   ├── shell-main.stl            # 主外殼(透氣孔+喇叭孔)
│   ├── sensor-baffle.stl         # 感測室隔艙
│   ├── led-diffuser.stl          # 燈環透光罩
│   ├── dc-clip.stl               # DC 線壓條
│   ├── wall-mount.stl            # 壁掛支架
│   └── dust-cap.stl              # 保養防塵蓋
├── f3d/
│   └── c3-gas-leak-sentry.f3d    # Fusion 360 原始檔
├── manual/
│   └── assembly.html             # 圖文組裝說明書
└── README.md
← 上一篇:ESP32-S3 電子墨水桌面資訊卡 📚 系列索引 🔜 下一篇:製作中 · 敬請期待