📋 專案概述
五年前搬新家,老媽巡訪的第一件事不是看採光,是蹲下去聞瓦斯爐:「這個沒有偵測器喔?」三週後她從北部寄來一個瓦斯偵測器,附說明書一張:「請每季測試按鈕確認功能正常。」——問題是,測試按鈕只能確認「它想叫的時候叫得出來」,確認不了「它聞得到的時候想叫」。
市售偵測器的三種死法,每一種都有人經歷過:
- 關掉嗶嗶後就再也不響:半夜低電量嗶叫擾人,一不做二不休把電池拔了——三個月後它變成廚房裝飾。市售機型拔電池後照樣亮燈,裝死給你看得很安詳。
- 電池沒電不告訴你:9V 方形電池在感測器壽命之前先走,換電池這件事在人類的習慣養成清單上排名倒數。
- 炒菜時狂叫,人被訓練成取關:誤報處理不好的偵測器,下場是被搬到陽台「暫放」,然後人間蒸發。
這台 NT$615 的 ESP32-C3 瓦斯哨兵把三種死法各對了一味藥,功能清單:
- 偵測:MQ-2(甲烷/液化氣/丙酮)+ DHT22 溫濕度——蒸氣與油煙的濕度特徵和漏氣不同,用來過濾誤報
- 不裝死:每 60 秒向 NAS 報心跳,3 分鐘沒報到 NAS 自己發推播「哨兵失聯」——它的失蹤本身就是告警事件
- 自我體檢:開機先測感測器,讀不到資料亮黃燈+上報
sensor_fail,而不是假裝天下太平 - 誤報有紀律:遲滯比較器(警戒 450/回防 380)+蒸氣辨識+停叫按鍵(10 分鐘後自動復活)
- 12V 適配器供電:MQ-2 的加熱絲本來就要 350mW 常駐——這台從頭到尾不假裝省電,也不拿電池騙你
成本盤算:同規格商用機 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-1 | 1 | ~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 元 | 商用機的零頭,附「壞了自己說」功能 |
⚠️ 定位說明:本機是「加分的哨兵」,不是安規認證設備。廚房對面牆上那顆依法規安裝的可燃氣體偵測器(如有)請保持原位——本機提供的是第二重覆蓋、即時推播、與「知道它活著」的安心。
🔌 接線圖
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Ω,資料線與電源線分开走。
🎯 它的一天:取樣、遲滯、心跳
🧭 裝在哪:甲烷的物理課
- 天然氣(甲烷)往上跑:裝在爐台上方、離天花板約 30cm——裝在地板旁的是液化氣(LPG)的職位,裝錯高度等於請它聞空氣
- 離爐台 1~2m 的水平距離:太近天天聞油煙被誤報拖死,太遠稀釋到超標要三倍的量,錯失的是時間
- 避開抽油機進風口正下方與窗邊:氣流把煙送來又把氣吹走,感測器會困惑到亂報
- MQ-2 的微溫是生命徵象:外殼設計讓手指摸得到感測室頂部——定期摸一下比任何自检按鈕都直覺
🖨️ 3D 列印設計
零件清單
| 零件 | 數量 | 材質建議 | 列印設定 | 備註 |
|---|---|---|---|---|
| 主外殼 | 1 | PLA | 0.2mm/3 壁/15% | 感測室四周 3×8 透氣孔陣列;喇叭開孔朝前下 15° |
| 感測室隔艙 | 1 | PLA | 0.2mm/3 壁/25% | MQ-2 與電路板物理分艙:熱源與電子隔離 |
| 燈環透光罩 | 1 | PLA(白) | 0.2mm/2 壁 | 正面留 0.8mm 薄壁,打光均勻不透點 |
| DC 線壓條 | 1 | PLA | 0.2mm/2 壁 | 12V 線出殼方向朝下,防潑水順勢流走 |
| 壁掛支架 | 1 | PETG | 0.2mm/4 壁/40% | 貼牆高裝;鍵孔+螺孔兩段式對位 |
| 防塵蓋(保養用) | 1 | PLA | 0.28mm/2 壁/10% | 大掃除時罩住感測室,防粉塵殺手 |
設計要點
- 感測室是「陽台」不是「房間」:四周透氣孔總面積 ≥ 感測窗面積,氣體擴散路徑越直,反應越快
- 加熱絲艙與電路艙隔牆:MQ-2 表面 40~50°C,貼板安裝會暖化附近膠材與鋁電解電容的壽命
- 蜂鳴器朝前下 15°:對準人站立時耳朵的方向,比水平開孔實測響 3dB(同一顆蜂鳴器,擺位就是音量)
- 所有開孔避開正上方水龍頭軌跡:廚房的水比瓦斯先到得早,濺水路徑要算
- 保養入口一顆螺絲:換感測模組(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 組裝說明書
- 上桌驗板:燒 demo 讀 ADC——對打火機短促噴一下(不點火!),讀值要衝高再回落;沒反應先查 AOUT 不是 DOUT
- 分艙鎖板:感測室隔艙先入殼,MQ-2 正面朝透氣孔;電路艙鎖主板,燈環用螺絲鎖透光罩再插 DIN
- 燭台標定(關鍵步):點蠟燭置於感測窗下 15cm,記下讀值爬升曲線,把
ALARM_ADC設在明顯低於點火濃度、高於日常炒菜峰值的位置(預設 450 是起手式) - 閉環測試:觸發告警→蜂鳴三段音→NAS 收到 3 包→停叫鍵 10 分鐘靜音→自動復活;四件事全過才算裝完
- 高裝定位:離天花板 30cm、爐台上風側 1.5m,12V 線沿牆固定;貼上標有「上次體檢日」的部署標籤
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| ADC 讀值恆 0 | C3 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 以下 |
🎯 進階延伸
- 双哨兵交叉驗證:陽台液化氣桶另裝一台(MQ-6 專用感測),两台同時告警=真事故、單台=查誤報——NAS 端一行邏輯的事
- 排氣扇聯動:NAS 收到告警順手開 Smart 插座上的排氣扇+關瓦斯電磁閥(Relay 模組 ~90 元);聯動裝置要能被人手動超控
- MQ-135 空氣版:同一支韌體換感測器,門檻常數重標——PM0.3 濾網+CO₂ 的讀書房監測版
- 斷電通報 UPS 化:加 18650 boost 備援小電(2 小時級),斷電時亮紅燈+發
power_fail——廚房半夜跳電你也知道 - firewall 日誌儀表板:把 heartbeat 的 ADC 曲線畫進 Grafana——三週的基線曲線自己會告訴你感測器何時該退休
🎯 結語
裝好那晚,老媽打電話來,第一句不是問造價,是「測試按鈕在哪?」我指了指燈環:「沒有按鈕,它每天自己考自己,不及格會變黃色。」她沉默兩秒說:「那黃色你要記得處理。」——這是對這台機器最準確的使用說明:它負責不裝死,你負責聽到它的求救。廚房從此多了個不睡覺的室友,它唯一的請求是一年換一次感測模組、以及大掃除時記得把防塵蓋幫它戴上。
📦 檔案清單
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