📋 專案概述

天氣轉涼後,機車電池又開始下午沒電。我家那顆用了四年的鋰電池撐不過今年的寒流,換了組新電池+附贈的「快速充電器」——说明书上那行小字我盯了十秒:「充電時請保持通風,遠離易燃物,請勿無人看管」。問題是:誰家充電不是無人看管?充電器插在玄關插座上,腳邊就是瑜珈墊、牛皮紙袋,以及每週五都會出現在那的紙箱——這不是恐嚇行銷,這是一場還沒發生的火。我認真去看了消防署的新聞稿:單一個鋰電池熱失控從冒煙到全燒,常常只有 60~90 秒,而第一陣煙比第一道火早了 2~5 分鐘。那 2~5 分鐘,就是本專案存在的全部理由。

市面上的選擇我全部想過一遍:

  1. 社區共享充電柜:一度電加服务费实質上一學期就够回本一台本機,更重要的是它假設你永遠在樓下充電——但我的車進不了電梯、更進不了充電柜。
  2. 定時插座:假設電池永遠從完全放電充到滿要恰好 6 小時。實際上從 40% 充到 98% 只要 1.5 小時,定時器讓它繼續 400mA 涓流到明天早上——這正是新聞裡「過充起火」的标准剧。
  3. 工業充電柜+七氟丙烷:对,那是答案,一萬五起。我一個公寓玄關不需要哈龍。

這台 ESP32-C6 充電哨兵把充電器插頭管成一段可稽核的流程:

帳目很單純:共享充電柜一年約 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-11~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 元

🔌 接線圖

ESP32-C6 DevKitM-1 · 玄關殼中樞 USB-C(獨立火牛) GPIO0 ACS A GPIO2 MQ2 A GPIO3 NTC GPIO10 SSR GPIO11 BUZ GPIO7 RGB ACS712-20A(火線穿孔) OUT→GPIO0|100mV/A・零點 2.5V MQ-2(吸煙口朝下) AOUT → GPIO2 NTC 貼充電器殼 分壓 → GPIO3 過零 SSR IN+ GPIO10 / IN- GND 110V 段全封阻燃盒 輸出插座 → 充電器 延長線火線(紅虛線)進 SSR BUZ 有源蜂鳴器 → GPIO11 燈條 8 顆 → GPIO7
▲ 全機 110V 只有「延長線→SSR→插座」一條直路,低壓域由獨立火牛供電——哨兵與哨兵管的路各走各的電源,才不會一起陣亡。

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 腳位連接說明
GPIO0ACS712 OUTC6 的 ADC1 與 ADC2 不同設計,ADC2 只在特定頻段可用——把「必須乾淨」的電流讀值釘在 ADC1_CH0 是省 debug 時間的保鮮符
GPIO2MQ-2 AOUT同樣走 ADC1;MQ-2 模組的 DOUT 門檻旋鈕不要信——比較器漂移是它最不可靠的部分,一律軟體判類比值
GPIO3NTC 分壓10K NTC+10K 上拉的對數曲線在 45~75°C 間每 5°C 還有 60mV 變化,判 65°C 門檻解析度绰綽有餘
GPIO10 / GPIO11SSR IN+ / 蜂鳴器C6 的 GPIO10/11 是無_STRapping_之爭的乾淨輸出腳,開機不會因為外設漏電卡進下載模式(換掉 8/9 那對 BOOT 腳是本專案的腳位規划核心)
GPIO7燈條 DINC6 的 RMT 發射通道任何 GPIO 都能路由;燈條 5V 供電、資料 3.3V 位準直推安全
⚠️ 本專案的安全前提:SSR 不是隔離開關、也不是保險絲。它是一條「軟體可以切斷的軟路徑」——充電器本身的保險絲、延長線的過載保護照舊要在。輸出額定別超過 SSR 的 10A/充電器的標稱電流;以及,把本裝置視為「降低風險」而非「豁免風險」:鋰電池體外鼓包、外殼燙手、異味,任何一項出現请直接断充下樓,不是來 debug 韌體。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
哨兵主殼(通風柵版)1PETG(自熄等級更佳)灰~3.5 hr
MQ-2 採樣腔(煙道杯)1PETG黑~40 min
ACS712 穿孔固定夾1PETG黑~15 min
SSR 散熱座+阻燃盒蓋1PETG黑~35 min
壁裝支架(離地 30cm)1PETG灰~25 min
燈條導光條1PLA(磨砂)白~15 min

列印設定

設計要點

  1. 高低壓分艙:殼內一隔板,SSR 與 110V 端子台獨佔一艙(僅一條低壓信號線穿越),低壓艙走線再亂也亂不到強電側
  2. MQ-2 恆溫預熱窗:感測頭下方挖 15mm 空隙,讓它呼吸——MQ-2 內建加熱絲本來就要 5V 常溫,悶住會讀到「自己的氣味」
  3. ACS712 夾在進線正中央:穿孔固定夾讓火線垂直穿模組圓孔,線材歪斜會造成 ±2% 的耦合誤差——校準時也要以安裝後的姿態校
  4. 燈條導光條沿殼底緣:從玄關一眼看到的是整條燈帶顏色而不是 8 顆燈珠——狀態語言要在 3 公尺外可讀

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📚 相依:板子選「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 組裝說明書
  1. MQ-2 先熟成 24 小時再校準:出廠的 MQ-2 加熱絲需要第一次連續通電 24 小時老化,零點才會穩。跳過這步,你的零點校準值會在一週內慢慢漂移,然後某一天燻一根蚊香它裝死
  2. 強電改延長線(唯一步驟):拿一條現成 3 孔延長線剪開中段,火線走 SSR、中性與地走端子台直連;全部接點進阻燃盒、盒蓋鎖平再通電。不碰牆壁插座、不改家裡的固定配線——這台裝置拔掉,你家就恢復原樣,這是公寓住户的修車道德
  3. ACS712 穿孔方向:延長線火線垂直穿過模組圓孔(P 端朝插座側)。穿反不會壞,但電流符號相反——好在 RMS 運算取平方,方向只影響你日後的直流擴展
  4. 感測器擺位:MQ-2 吸煙口朝下離地 30cm 壁裝(玄關平面流層);NTC 用鋁帶綁在充電器散熱格外殼(貼金屬面、別貼塑膠外殼空腔)
  5. 三情境驗證:①接手機充電器(<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

🎯 進階延伸

📦 檔案清單

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                  # 說明文件
← 上一篇:ESP32-S3 浴室潮氣哨兵 📚 系列索引 下一篇:敬請期待(系列索引)→