📋 專案概述
天氣轉涼後,機車電池又開始下午沒電。我家那顆用了四年的鋰電池撐不過今年的寒流,換了組新電池+附贈的「快速充電器」——说明书上那行小字我盯了十秒:「充電時請保持通風,遠離易燃物,請勿無人看管」。問題是:誰家充電不是無人看管?充電器插在玄關插座上,腳邊就是瑜珈墊、牛皮紙袋,以及每週五都會出現在那的紙箱——這不是恐嚇行銷,這是一場還沒發生的火。我認真去看了消防署的新聞稿:單一個鋰電池熱失控從冒煙到全燒,常常只有 60~90 秒,而第一陣煙比第一道火早了 2~5 分鐘。那 2~5 分鐘,就是本專案存在的全部理由。
市面上的選擇我全部想過一遍:
- 社區共享充電柜:一度電加服务费实質上一學期就够回本一台本機,更重要的是它假設你永遠在樓下充電——但我的車進不了電梯、更進不了充電柜。
- 定時插座:假設電池永遠從完全放電充到滿要恰好 6 小時。實際上從 40% 充到 98% 只要 1.5 小時,定時器讓它繼續 400mA 涓流到明天早上——這正是新聞裡「過充起火」的标准剧。
- 工業充電柜+七氟丙烷:对,那是答案,一萬五起。我一個公寓玄關不需要哈龍。
這台 ESP32-C6 充電哨兵把充電器插頭管成一段可稽核的流程:
- ACS712 電流窗:用交流電流判定「真的在充」還是「插著沒充」——涓流跌到 0.3A 以下維持 30 秒即認定充電完成,自動切斷輸出,把過充窗口直接關掉
- MQ-2 前置預警:充電期間煙霧/可燃氣體偵測分兩級——第一級先嗶 5 下+Telegram 推播(預警期),第二級立即斷電+連續爆嗶,把那 2~5 分鐘的煙預警換成人趕回家的時間
- 超溫斷電:C6 內建溫度sensor+外置充電器表面 NTC,任一超過 65°C 直接切輸出(充電器燙手本身就是熱失控前兆之一)
- 8 小時硬上限:無論電流曲線多漂亮,充電超過 8 小時強制斷電並推播——軟體再聰明也要有保險絲
- 每次充電日報:開始/結束時間、耗電量(VAh 積分)、峰值溫度、有無警報事件,全部推播——一個學期下來你會比你老婆更了解那顆電池
帳目很單純:共享充電柜一年約 1,500~2,500 元、玄關火災保險理賠免賠額 30,000 元起、而本專案 640 元。它不保證不失火——它做的是把「無人看管的過充」從失火原因清單裡刪除,並在真的開始冒煙的頭幾秒內讓你知道。
為什麼選 ESP32-C6?
| 需求 | C6 的優勢 |
|---|---|
| ADC 讀值品質 | C6 的 ADC1 是 ESP32 家族近年改設計最多的一代——DLM 錯誤誤差校準后 INL/DNL 都收在 ±6LSB 內,ACS712 每安培 100mV 的訊號不用外接基準就能讀得穩 |
| 單核就夠,但要乾淨 | 本專案取樣週期硬(50Hz RMS 窗),RISC-V 單核沒有雙核快取一致性干擾,取樣抖動比多核平台更可預期 |
| 玄關的 Wi-Fi _coverage_ | 公寓玄關離主機最遠、隔兩道牆,C6 的 Wi-Fi 6(含 TWT/OFDMA)在邊緣訊號下重傳效率明顯好於舊晶片——警報推播這種「一輩子只發三次、每次都要到」的訊息輸不起 |
| 預留 Thread 出口 | 802.15.4 原生支援:日後可把它做成 Thread 節點接進 Home Assistant,警報直接拉開玄關燈+手機全組推送;本版本先走 Wi-Fi 保最低門檻 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C6-DevKitM-1 | 1 | ~135 元 | C6-MINI-1 模组方案;免 PSRAM、免外部 Flash 加購 |
| 電流感測 | ACS712-20A 模組 | 1 | ~75 元 | 20A 版對 0.5~3A 充電器解析最好;不要買 5A/30A 版(解析度差 4 倍/灵敏度差 6 倍) |
| 氣體感測 | MQ-2 模組(數位+類比雙輸出) | 1 | ~45 元 | 偵測烷類/煙霧顆粒;上電預熱 24 小時再校準是鐵律 |
| 輸出切斷 | 過零 SSR 模組(SPC5821) | 1 | ~65 元 | 串在插座火線上;充電器是阻性負載,過零與否影響不大但同料號好買 |
| 現場警報 | 5V 有源蜂鳴器 | 1 | ~12 元 | 「有源」=內部自振,GPIO 給高電位就叫——預警音要能在關著電視的客廳聽見 |
| 狀態燈條 | WS2812B 燈條(8 顆) | 1 | ~35 元 | 剪下 8 顆:待機灰、充電呼吸、完成綠閃、預警橙、失火紅爆閃 |
| 溫度偵測 | NTC 10K 防水探頭(DS18B20 可代) | 1 | ~25 元 | 貼在充電器外殼散熱格上,測「充電器表面」而非氣溫 |
| 輸出插座 | 三孔插座模組(10A) | 1 | ~35 元 | 機殼輸出端;輸入端用現成延長線剪線接 |
| 電源 | 5V 1A 火牛(USB 線剪接) | 1 | ~48 元 | 與充電器分開插——它掛了哨兵還在,才叫哨兵 |
| 外殼與雜項 | 3D 列印殼(通風柵版)、端子台、阻燃膠、魔鬼氈 | 1 | ~170 元 | 殼體離地 30cm 壁裝(MQ-2 吸煙口對流佳位) |
| 合計 | ~640 元 |
🔌 接線圖
ASCII 對照表
ESP32-C6-DevKitM-1 週邊
────────────────── ─────────────────────────
3V3 ──── 紅線 ──── ACS712 VCC(3.3V 供電=讀值域直接對齊 ADC)
GND ──── 黑線 ──── 全部 GND(含 SSR IN-)
GPIO0 ──── 黃線 ──── ACS712 OUT(ADC1_CH0;全機唯一「必須 ADC1」的腳)
GPIO2 ──── 紅線 ──── MQ-2 AOUT(ADC1_CH2;MQ-2 高阻抗源,走短線)
GPIO3 ──── 藍線 ──── NTC 分壓中點(10K 上拉至 3V3)
GPIO10 ──── 綠線 ──── SSR IN+(HIGH=輸出插座通電)
GPIO11 ──── 橘線 ──── 蜂鳴器 +(- 接 GND,內部已備晶體管驅動電路則直推)
GPIO7 ──── 紫線 ──── WS2812 燈條 DIN(5V 供電)
※ ACS712 零點輸出 2.5V,必須由 3V3 供電才能對齊 0~3.3V 讀值域(用 5V 供電要加分壓,自找麻煩)
※ 110V 側:延長線剝開,火線進 SSR「~」、出「~」到輸出插座樞心孔;中性線與地線以端子台直通
詳細接線
| ESP32-C6 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO0 | ACS712 OUT | C6 的 ADC1 與 ADC2 不同設計,ADC2 只在特定頻段可用——把「必須乾淨」的電流讀值釘在 ADC1_CH0 是省 debug 時間的保鮮符 |
| GPIO2 | MQ-2 AOUT | 同樣走 ADC1;MQ-2 模組的 DOUT 門檻旋鈕不要信——比較器漂移是它最不可靠的部分,一律軟體判類比值 |
| GPIO3 | NTC 分壓 | 10K NTC+10K 上拉的對數曲線在 45~75°C 間每 5°C 還有 60mV 變化,判 65°C 門檻解析度绰綽有餘 |
| GPIO10 / GPIO11 | SSR IN+ / 蜂鳴器 | C6 的 GPIO10/11 是無_STRapping_之爭的乾淨輸出腳,開機不會因為外設漏電卡進下載模式(換掉 8/9 那對 BOOT 腳是本專案的腳位規划核心) |
| GPIO7 | 燈條 DIN | C6 的 RMT 發射通道任何 GPIO 都能路由;燈條 5V 供電、資料 3.3V 位準直推安全 |
⚠️ 本專案的安全前提:SSR 不是隔離開關、也不是保險絲。它是一條「軟體可以切斷的軟路徑」——充電器本身的保險絲、延長線的過載保護照舊要在。輸出額定別超過 SSR 的 10A/充電器的標稱電流;以及,把本裝置視為「降低風險」而非「豁免風險」:鋰電池體外鼓包、外殼燙手、異味,任何一項出現请直接断充下樓,不是來 debug 韌體。
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 哨兵主殼(通風柵版) | 1 | PETG(自熄等級更佳) | 灰 | ~3.5 hr |
| MQ-2 採樣腔(煙道杯) | 1 | PETG | 黑 | ~40 min |
| ACS712 穿孔固定夾 | 1 | PETG | 黑 | ~15 min |
| SSR 散熱座+阻燃盒蓋 | 1 | PETG | 黑 | ~35 min |
| 壁裝支架(離地 30cm) | 1 | PETG | 灰 | ~25 min |
| 燈條導光條 | 1 | PLA(磨砂) | 白 | ~15 min |
列印設定
- 材質全場 PETG:這不是美觀件是安全件——PLA 的玻璃轉化溫度約 60°C,萬一充電器外殼真的高溫輻射到殼體,PLA 會先軟;PETG 撐到 75°C 以上且自熄性較好。能買到阻燃級(UL94 V-0 標稱)耗材更好
- 主殼四面通風柵:MQ-2 需要擴散對流才能「聞到」煙——密閉殼體裡的感測器是擺設;柵條縫 5mm(防手指、通煙)
- 採樣腔 0° 圓盤紋+底部 6 入孔:煙比空氣重?錯——熱煙上升、冷煙貼地,玄關充電情境取離地 30cm 是兩者的平流層交集,腔體讓煙在感測頭周圍多留 0.5 秒
- SSR 座加散熱鰭片:SPC5821 在 2A 連續耗散約 1.6W,PETG 座背面貼 20mm 鋁散熱塊(列印件預留卡槽)
設計要點
- 高低壓分艙:殼內一隔板,SSR 與 110V 端子台獨佔一艙(僅一條低壓信號線穿越),低壓艙走線再亂也亂不到強電側
- MQ-2 恆溫預熱窗:感測頭下方挖 15mm 空隙,讓它呼吸——MQ-2 內建加熱絲本來就要 5V 常溫,悶住會讀到「自己的氣味」
- ACS712 夾在進線正中央:穿孔固定夾讓火線垂直穿模組圓孔,線材歪斜會造成 ±2% 的耦合誤差——校準時也要以安裝後的姿態校
- 燈條導光條沿殼底緣:從玄關一眼看到的是整條燈帶顏色而不是 8 顆燈珠——狀態語言要在 3 公尺外可讀
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📚 相依:板子選「ESP32C6 Dev Module」,USB CDC On Boot: Enabled;Lib Manager 裝
📚 相依:板子選「ESP32C6 Dev Module」,USB CDC On Boot: Enabled;Lib Manager 裝
Adafruit NeoPixel。C6 為單核 RISC-V,Wi-Fi 堆疊與使用者任務分優先級搶佔——取樣迴路以 hardware timer 餵旗標,不被 Wi-Fi 重連卡住。/* ============================================================
* ESP32-C6 電動車充電安全哨兵 | EV Charge Sentry
* ------------------------------------------------------------
* 功能:ACS712 電流窗判充電狀態 + MQ-2 二級煙霧預警(二級直接斷電)
* + NTC 超溫切斷 + 8 小時硬上限 + 每次充電日報(Telegram)
* 硬體:ESP32-C6-DevKitM-1 + ACS712-20A(GPIO0) + MQ-2(GPIO2)
* + NTC10K(GPIO3) + SSR(GPIO10) + 蜂鳴器(GPIO11) + 燈條(GPIO7,8顆)
* 供電:獨立 5V 火牛 → USB-C(與充電器不同插座,火燒不到哨兵)
* 相依:Arduino core for ESP32 v3.x(板型 ESP32C6 Dev Module)/ Adafruit_NeoPixel
* 安全模型:所有警報路徑「斷電優先於通知」——推播可能失敗,GPIO 不會。
* ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <time.h>
#include <Adafruit_NeoPixel.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";
/* ------------------------------------------------------------
* 2. 腳位
* ------------------------------------------------------------ */
const uint8_t PIN_ACS = 0; // ADC1_CH0:ACS712 OUT(唯一必須 ADC1 的通道)
const uint8_t PIN_MQ2 = 2; // ADC1_CH2:MQ-2 AOUT
const uint8_t PIN_NTC = 3; // ADC1_CH3:NTC 分壓中點
const uint8_t PIN_SSR = 10; // 高=插座通電
const uint8_t PIN_BUZ = 11; // 高=叫
const uint8_t PIN_STRIP = 7;
const uint8_t STRIP_LEDS = 8;
/* ------------------------------------------------------------
* 3. 判準參數(以 48V/2A 充電器 + 玄關 26°C 環境調過)
* ------------------------------------------------------------ */
const float ACS_ZERO = 2.470; // 出廠是 2.500V——實際零點校準後覆寫這個值
const float ACS_SENS = 0.100; // 20A 版:100mV/A
const float I_START = 0.8; // >0.8A 且穩定 5 秒 = 開始充電
const float I_STOP = 0.3; // <0.3A 穩定 30 秒 = 充電完成
const uint32_t CHARGE_LIMIT_MS = 8UL*3600000; // 8 小時硬上限(保險絲邏輯)
const float TEMP_CUTOFF = 65.0; // NTC 表面溫度切斷線
const uint16_t MQ2_ALARM1 = 60; // 預警增量(ADC 原始值,高於校準零點)
const uint16_t MQ2_ALARM2 = 150; // 斷電增量(≈明火前 2 分鐘的顆粒濃度)
/* ------------------------------------------------------------
* 4. 全域狀態
* ------------------------------------------------------------ */
enum Sentry { STATE_IDLE, STATE_CHARGING, STATE_ALARM1, STATE_ALARM2 };
Sentry state = STATE_IDLE;
uint32_t chargeStartMs = 0, idleSinceMs = 0, stateSinceMs = 0;
uint16_t mq2Zero = 0; // 上電 6 秒校準的 MQ-2 零點
float ampsNow = 0, ntcNow = 0;
float vahAcc = 0; // 本次充電 VAh 積分
float peakTemp = 0;
bool outputOn = false; // SSR 實際狀態(≠ 允許充電:警報會切輸出但狀態留在充電中)
uint32_t lastRmsMs = 0, lastSlowMs = 0, lastBlinkMs = 0, lastPushMs = 0;
uint8_t rmsSamples = 0; float rmsAccum = 0;
float instPeak = 0; // 320ms 窗內峰值,再對 5 秒做滑動
uint32_t chargeStartWall = 0; // 墙上時間(日報用)
char report[260];
Adafruit_NeoPixel strip(STRIP_LEDS, PIN_STRIP, NEO_GRB + NEO_KHZ800);
/* ------------------------------------------------------------
* readAmps —— 半週期取樣算 RMS
* 16 點/週 × 2 週 ≈ 320ms:交流 60Hz 下「一次 reading」毫無意義,
* 不取 RMS 的話 0.8A 門檻會被 50Hz 過零線打得亂跳
* 回傳:本次 320ms 窗的等效電流(A)
* ------------------------------------------------------------ */
float readAmps() {
float sumSq = 0;
for (int i = 0; i < 32; i++) {
float v = analogReadMilliVolts(PIN_ACS) / 1000.0;
sumSq += (v - ACS_ZERO) * (v - ACS_ZERO);
delayMicroseconds(5200); // 60Hz 半週 8.3ms,5.2ms ≈ 1/6 週期步距
}
return sqrtf(sumSq / 32.0) / (ACS_SENS * 1000.0) * 1.4142 / 1.0; // 平均值→約 RMS(正弦近似)
}
/* ------------------------------------------------------------
* readNTC —— B 值換算
* NTC 對數曲線只在常用區間近似線性,B 值式才是正解
* 回傳:攝氏溫度
* ------------------------------------------------------------ */
float readNTC() {
float v = analogReadMilliVolts(PIN_NTC);
if (v < 30) return 999; // 探頭脫落(拉到 0mV)=故障安全側:視為高溫
if (v > 3250) return -99; // 探頭斷線(拉到 3V3)
float r = 10000.0 * v / (3300.0 - v); // 10K 上拉、NTC 下分壓
return 1.0 / (logf(r / 10000.0) / 3950.0 + 1.0 / 298.15) - 273.15; // B=3950
}
/* ------------------------------------------------------------
* setOutput —— 輸出級(唯一動 SSR 的出入口)
* 參數:on 通電與否;reason 寫入序列日誌(事後稽核用)
* 為什麼包函式:警報路徑手滑直接 digitalWrite 繞過日誌 = 事後查無原因
* ------------------------------------------------------------ */
void setOutput(bool on, const char* reason) {
if (on == outputOn) return;
outputOn = on;
digitalWrite(PIN_SSR, on ? HIGH : LOW);
Serial.printf("[OUT] %s(%s)\r\n", on ? "ON " : "OFF", reason);
}
/* ------------------------------------------------------------
* alarmHandler —— 二級警報:先斷電、再通知、無限爆嗶直到人工復歸
* 為什麼「先斷再報」:HTTPS 握手的 300ms 裡火可能已經著了
* ------------------------------------------------------------ */
void alarmHandler(byte level) {
if (level >= 2 && state != STATE_ALARM2) {
state = STATE_ALARM2; stateSinceMs = millis();
setOutput(false, "MQ-2 二級警報");
sendTelegram("🔥 充電哨兵【二級警報】偵測到濃煙,輸出已切斷!請立即查看玄關!");
} else if (level == 1 && state == STATE_CHARGING) {
state = STATE_ALARM1; stateSinceMs = millis();
sendTelegram("⚠️ 充電哨兵預警:偵測到氣體/煙霧前兆,請查看充電器與電池。");
}
}
/* ------------------------------------------------------------
* sendTelegram —— 警報與日報推播(HTTP over Wi-Fi;失敗由呼叫端重試)
* 注意:警報等級的訊息會以 60 秒間隔重試直到送達(見 loop 末段)
* ------------------------------------------------------------ */
bool sendTelegram(const String& msg) {
if (WiFi.status() != WL_CONNECTED) return false;
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 + "\"}";
int st = http.POST(json);
http.end();
return st == 200;
}
void setup() {
Serial.begin(115200);
pinMode(PIN_SSR, OUTPUT); pinMode(PIN_BUZ, OUTPUT);
digitalWrite(PIN_SSR, LOW); // 開機安全側:先斷輸出再說任何話
digitalWrite(PIN_BUZ, LOW);
analogSetPinAttenuation(PIN_ACS, ADC_11db); // 0~3.3V 全量程
strip.begin(); strip.clear(); strip.show();
/* MQ-2 零點校準:加熱絲穩定需數秒,上電先讀 6 秒取中位數當零點
(真正的老化校準是連續通電 24 小時後重跑一次——見問題排除) */
Serial.print("MQ-2 零點校準");
uint16_t buf[40];
for (int i = 0; i < 40; i++) { buf[i] = analogRead(PIN_MQ2); delay(150); Serial.print("."); }
// 簡易排序取中位數(泡沫濾除感測器冷啟的跳動值)
for (int i = 0; i < 40; i++) for (int j = i + 1; j < 40; j++)
if (buf[j] < buf[i]) { uint16_t t = buf[i]; buf[i] = buf[j]; buf[j] = t; }
mq2Zero = buf[20];
Serial.printf(" 零點=%u\r\n", mq2Zero);
configTzTime("CST-8", "pool.ntp.org", "time.google.com");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("連線 Wi-Fi");
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) { delay(500); Serial.print("."); }
Serial.println(WiFi.status() == WL_CONNECTED ? " OK" : " 失敗(將持續重試;警報不受影響)");
snprintf(report, sizeof(report), "充電哨兵上線|MQ2 零點 %u|按狀態燈色確認", mq2Zero);
sendTelegram(report);
}
void loop() {
uint32_t now = millis();
int mq2Delta = analogRead(PIN_MQ2) - mq2Zero; // 主迴路每圈都聞:警報延遲 < 一圈
ntcNow = readNTC();
if (ntcNow > peakTemp) peakTemp = ntcNow;
/* --- 1. 警報優先級:任何時刻,MQ2 二級或超溫立即切輸出 --- */
if (state == STATE_ALARM2) {
digitalWrite(PIN_BUZ, (now / 120) % 2); // 120ms 爆嗶直到人工復歸(重開機)
if (now - lastPushMs > 60000) { lastPushMs = now; sendTelegram(report); }
strip.fill(strip.Color(255, (now / 120 % 2) * 60, 0)); strip.show();
return; // 二級狀態不回應任何狀態機邏輯
}
if (ntcNow > TEMP_CUTOFF && state != STATE_IDLE) {
setOutput(false, "NTC 超溫切斷");
snprintf(report, sizeof(report), "⚠️ 充電中斷:充電器表面 %.0f°C 超過 %.0f°C,輸出已切。", ntcNow, TEMP_CUTOFF);
sendTelegram(report);
state = STATE_ALARM1; stateSinceMs = now;
}
if (mq2Delta >= MQ2_ALARM2) alarmHandler(2);
else if (mq2Delta >= MQ2_ALARM1) alarmHandler(1);
/* --- 2. 電流窗:每 320ms 一個 RMS 樣本 --- */
if (now - lastRmsMs >= 320) { lastRmsMs = now; ampsNow = readAmps(); }
/* --- 3. 狀態機(1 秒節流) --- */
if (now - lastSlowMs >= 1000) {
lastSlowMs = now;
switch (state) {
case STATE_IDLE:
if (ampsNow > I_START) {
static uint32_t overSince = 0;
if (overSince == 0) overSince = now;
if (now - overSince >= 5000) { // 連續 5 秒 >0.8A = 真的在充
overSince = 0;
state = STATE_CHARGING; stateSinceMs = now;
chargeStartMs = now; chargeStartWall = (uint32_t)time(nullptr);
vahAcc = 0; peakTemp = ntcNow;
setOutput(true, "偵測到充電電流"); // 正常路徑輸出本該是通的,這裡是防禦性重宣示
snprintf(report, sizeof(report), "🔌 開始充電|%.2fA|表面 %.0f°C", ampsNow, ntcNow);
sendTelegram(report);
}
} else stateSinceMs = now;
break;
case STATE_CHARGING:
if (ampsNow < I_STOP) {
if (idleSinceMs == 0) idleSinceMs = now;
if (now - idleSinceMs >= 30000) { // 涓流跌平 30 秒 = 充滿
state = STATE_IDLE; idleSinceMs = 0;
snprintf(report, sizeof(report),
"✅ 充電完成|耗 %.1f Wh|峰值 %.0f°C|自動斷電避免過充", vahAcc, peakTemp);
sendTelegram(report);
}
} else idleSinceMs = 0;
if (now - chargeStartMs > CHARGE_LIMIT_MS) { // 8 小時保險絲
state = STATE_ALARM1; stateSinceMs = now;
setOutput(false, "8 小時硬上限");
snprintf(report, sizeof(report), "🛑 充電超過 8 小時仍未完成(%.1fWh),已強制斷電——請檢查電池健康度", vahAcc);
sendTelegram(report);
}
if (state == STATE_CHARGING && WiFi.status() == WL_CONNECTED)
vahAcc += 110.0 * ampsNow / 3600.0; // 1 秒積一分:W×s→Wh(以 110V 名值近似 PF=1)
break;
case STATE_ALARM1: // 預警態:輸出切著,人工復歸=重開機或按殼側復歸鍵(進階版)
if (mq2Delta < MQ2_ALARM1 / 2 && now - stateSinceMs > 600000) {
state = STATE_IDLE; stateSinceMs = now; // 10 分鐘煙散且逾 10 分鐘:自動回待機(不自動復電)
sendTelegram("🌬 濃度已降回安全區。輸出維持關閉,確認安全後請手動重啟裝置。");
}
break;
case STATE_ALARM2: break; // 於上方優先段處理
}
}
/* --- 4. 輸出守則:非充電態不允許通電(警報/待機都切) --- */
if (state == STATE_ALARM1 && outputOn) setOutput(false, "預警態禁止輸出");
/* --- 5. 狀態燈與蜂鳴(燈:3 公尺可讀;嗶:僅預警 5 下) --- */
if (now - lastBlinkMs >= 30) {
lastBlinkMs = now;
uint32_t c; uint8_t n = STRIP_LEDS;
strip.clear();
switch (state) {
case STATE_IDLE: c = strip.Color(18, 18, 22); strip.fill(c, 0, n); break;
case STATE_CHARGING: { // 呼吸跑馬:8 顆輪走=「電正在進去」
uint8_t head = (now / 90) % n;
for (uint8_t i = 0; i < n; i++)
strip.setPixel(i == head ? 0 : (i == (head + n - 1) % n ? 1 : 2),
i == head ? 120 : 50, i == head ? 60 : 20);
break; }
case STATE_ALARM1: c = strip.Color(255, (now / 200 % 2) * 120, 0); strip.fill(c, 0, n);
static bool beeped = false;
if (!beeped) { for (int i = 0; i < 5; i++) { digitalWrite(PIN_BUZ, HIGH); delay(160); digitalWrite(PIN_BUZ, LOW); delay(160); } beeped = true; }
if (state == STATE_IDLE || state == STATE_CHARGING) beeped = false;
break;
default: strip.fill(strip.Color(40, 40, 40), 0, n);
}
strip.show();
}
/* --- 6. Wi-Fi 自癒(2 分鐘重試;警報不等 Wi-Fi) --- */
static uint32_t lastWifiTry = 0;
if (WiFi.status() != WL_CONNECTED && now - lastWifiTry > 120000) {
lastWifiTry = now; WiFi.reconnect();
}
}
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- MQ-2 先熟成 24 小時再校準:出廠的 MQ-2 加熱絲需要第一次連續通電 24 小時老化,零點才會穩。跳過這步,你的零點校準值會在一週內慢慢漂移,然後某一天燻一根蚊香它裝死
- 強電改延長線(唯一步驟):拿一條現成 3 孔延長線剪開中段,火線走 SSR、中性與地走端子台直連;全部接點進阻燃盒、盒蓋鎖平再通電。不碰牆壁插座、不改家裡的固定配線——這台裝置拔掉,你家就恢復原樣,這是公寓住户的修車道德
- ACS712 穿孔方向:延長線火線垂直穿過模組圓孔(P 端朝插座側)。穿反不會壞,但電流符號相反——好在 RMS 運算取平方,方向只影響你日後的直流擴展
- 感測器擺位:MQ-2 吸煙口朝下離地 30cm 壁裝(玄關平面流層);NTC 用鋁帶綁在充電器散熱格外殼(貼金屬面、別貼塑膠外殼空腔)
- 三情境驗證:①接手機充電器(<0.8A)確認「不會誤判充電」;②接充電池看呼吸燈與推播;③充滿後(或手動拔載具)30 秒內自動斷電。三題全過才算完成
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| ACS712 沒電流也讀到 0.3A 在跳 | 零點不是理論 2.500V(個體差異+3.3V 供電偏移) | 空載讀 30 秒取平均,把 ACS_ZERO 改成實測值——這是每次換插頭環境都要做一次的事(寫在韌體 v2 TODO) |
| MQ-2 頻繁誤報(炒菜的味道) | 玄關連接廚房,油煙就是可燃顆粒 | 這是功能不是故障——把哨兵移到離廚房門 2m 以上;或把 MQ2_ALARM1 由 60 提到 90,代價是預警時間少 1 分鐘 |
| 充到一半「完成」推播亂飛 | 充電器脈衝充電模式讓電流週期性掉到 0.3A 以下 | 把 I_STOP 降到 0.15A、涓流判定的 30 秒拉到 120 秒;多數脈衝充電器的「真充滿」是持續掉零不是間歇 |
| NTC 讀值比實溫高很多 | 探頭貼到發熱元件(SSR 散熱塊)而非充電器 | 探頭重新綁到充電器外殼散熱格,線拉直不要繞過 SSR 艙 |
| 燈條首顆亂閃 | GPIO7 上電瞬間的位準抖動鎖存了髒資料 | 已在 setup() 先 clear;仍異常就加一顆 330Ω 串在 DIN,並確認燈條 GND 與 C6 共地 |
| 推播都送不出去 | 玄關 Wi-Fi 訊號邊緣(這是它被選為挑戰目標的真實原因) | 確認 router 5GHz 沒獨佔(C6 支援 2.4/5G 雙頻但 Arduino 棧以 2.4G 最穩);真的太遠就加一台 TP-Link 中繼,或等進階章節把它搬上 Thread |
🎯 進階延伸
- 搬上 Thread:C6 的 802.15.4 開邊界router/節點模式,用 OpenThread 把警報接進 Home Assistant——玄關沒 Wi-Fi 訊號的最終解,也是 Matter 生態的門票;協定堆疊請參考 ESP-IDF 的 openthread 範例
- 復歸按鍵:殼側加一顆防水微動(GPIO1,上拉),警報解除不再靠拔拔插插——但復歸只清狀態、不自動復電,復電仍要長按 3 秒確認,誤觸防護
- 真·功率積分:ACS712 只量電流,VAh 以 110V 名值近似;要真瓦時數換扎 ZMPT101B 電壓模組+同相同步取樣算 PF——日報的「耗 Wh」從近似變計量級
- 多車版:C6 剩餘 GPIO 還有 5 根,加一顆 2 路 SSR 模組管兩台充電器,ACS712 兩顆——電流窗邏輯完全複用
📦 檔案清單
ev-charge-sentry/
├── src/
│ └── charge-sentry.ino # ESP32-C6 程式碼(本頁完整程式)
├── stl/
│ ├── sentry-shell.stl # 主殼(PETG 通風柵版)
│ ├── mq2-chamber.stl # MQ-2 採樣腔
│ ├── acs-clip.stl # ACS712 穿孔固定夾
│ ├── ssr-heatsink-cover.stl # SSR 散熱座+阻燃盒蓋
│ ├── wall-mount.stl # 壁裝支架(離地 30cm)
│ └── light-diffuser.stl # 燈條導光條
├── f3d/
│ └── charge-sentry.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件