📋 專案概述

今年 7 月電費單寄來 2,847 元——比隔壁租屋套房多了近一倍。我開始玩「插座式電力計」:把它插在冷氣上,測出來冷氣很乖;插在冰箱上,冰箱也很乖。問題是台電計度數減掉「我插過的那幾個」,每天還是憑空消失 2.1 度。插座式電力計一次只能看一個插座,永遠抓不出「總和對不起來」的兇手。

兇手藏在總開關後面:老冰箱老化發熱的封條、除濕機背後永遠亮著的待機燈、還有前一任房客接去陽台不知接了什麼的回路。要抓它,你需要的是一顆「看得到全屋」的眼睛——也就是夾在主線上的互感器(CT)。

查過市面上的方案:

  1. Shelly EM / 開閉所級 CT 監控:1,600~2,500 元,功能正確但——它假設你家有 Wi-Fi 覆蓋的配電箱、而且你願意讓資料經過廠商雲端。
  2. 中華電「智慧插座」類服務:月租型,一年租金夠買三顆本專案。
  3. 手動抄電表:免費,但你無法知道「凌晨 3 點誰在耗電」——而待機小偷恰恰只在凌晨耗電。

關鍵洞察:非侵入式(Non-Intrusive)是重點——CT 夾在火線外面就能測電流,不用斷電、不用拆線、不用電匠,租屋也裝得起來(搬走時取下來還原,配電箱毫髮無傷)。這個專案用 不到 300 元完成:

成本效益:台電夏季住宅 1,081 度以上級距 5.028 元/度。只要這個專案幫你抓出每天 1 度的待機小偷,一年省 365 度 ≈ 1,800 元——回本時間 6 個月,之後全是賺的。

為什麼選 ESP32-C6?

需求C6 的優勢
配電箱裡收訊全系列唯一 Wi-Fi 6:OFDMA + BSS Coloring 在鐵門衰減 + 邻居 AP 擁擠的環境下,吞吐量與穩定度明顯較好
單核就夠R1 單核 160MHz 跑 50Hz×2 取樣 + RMS 運算,CPU 佔用不到 15%
ADC 夠用12-bit SAR ADC 對 50Hz 正弦取樣 1024 點/週期,精度遠超計費需求(家用電表容許誤差 ±2%,本專案校準後約 ±3%)
體積與發熱QFN 封裝模组体积小,塞進卡扣式外殼不塞車;單核發熱低,密閉鐵箱旁不燙手

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C6-DevKitC-11~120 元Wi-Fi 6;需 core ≥ 3.0 支援 C6
電流互感器SCT-013-100A(無內建負載電阻版)1~65 元選「無內建電阻」版,自己配電阻才準
取樣電阻10Ω 5W 水泥電阻1~5 元CT 負載;100A→10Ω 滿檔約 33Vp 需再分壓
分壓/偏壓10kΩ ×3、10µF/25V ×2、100nF ×21 組~8 元訊號鏈:CT→分壓→直流偏置 1.65V→ADC
電源5V 1A USB 電源 + 線1~40 元配電箱旁通常有检修插座;沒有就拉延長線
顯示0.96" SSD1306 OLED (I2C)1~75 元可選;不要再省 75 元,開機校準比較方便
合計~313 元不含 OLED 則 238 元

🔌 接線圖

SCT-013 CT 夾 主火線穿過圓心 訊號鏈(洞洞板) 10Ω 負載 → 分壓÷4 +1.65V 偏置 → 隔直 10µF ESP32-C6 DevKitC-1 · Wi-Fi 6 GPIO0 (ADC1_CH0) GPIO6 / GPIO7 3V3 / GND OLED 0.96" SDA=GPIO6 · SCL=GPIO7 CT 兩線 → 洞洞板訊號鏈(無極性) 橘線:偏置後 0~3.3V 訊號 → GPIO0 綠線:I2C(VCC=3V3)
▲ CT 輸出是交流、可正可負,ADC 吃不下——訊號鏈把它分壓後抬到 1.65V 中點,正負半週都落在 0~3.3V 內。這是全篇最多人翻車的地方。

詳細接線

ESP32-C6 腳位連接說明
GPIO0(ADC1_CH0)訊號鏈輸出C6 的 ADC1 從 GPIO0 起;GPIO1 受 Wi-Fi 雜訊干擾,不要用
GPIO6 / GPIO7OLED SDA / SCL軟體 I2C 任意腳,避開 strapping(GPIO8/9)
3V3 / GNDOLED VCC / GNDOLED 與訊號鏈偏置都從 3V3 取,基準才一致
5V / GNDUSB 供電板子經 Type-C 供電亦可
ASCII 對照表(訊號鏈由 CT 往 ADC 方向) SCT-013 ──┬──[10Ω 5W]──┐ CT 負載:100A → ±33Vp │ │ │ [R1 30k]──┐ 分壓 ÷4:±33Vp → ±8.2Vp │ [R2 10k] (仍超 ADC,靠軟體限幅+校準取線性段) │ │ │ [10µF]── GPIO0 偏置 1.65V(3V3 經 10k+10k 分壓 + 10µF 濾波) │ │ └──────────────┴── GND 隔直 10µF 讓負半週不進 3V3 DevKitC-1 ┌──────────────────┐ │ GPIO0 ○──── 訊號鏈輸出 │ GPIO6 ○──── OLED SDA │ GPIO7 ○──── OLED SCL │ 3V3 ○──── OLED VCC + 偏置分壓 │ GND ○──── 共地(訊號鏈一點共地,勿迴路) └──────────────────┘
⚠️ CT 夾必須夾在火線(黑線)單芯上,夾整束(黑+白一起)淨電流為零、永遠讀 0。配電箱內操作請拉總開關後再夾——CT 不接線不碰銅,但配電箱裡不該有活線作業。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
卡扣式盒體(配電箱外壁)1PETG灰~2 hr
面板框(OLED 視窗)1PETG灰~40 min
CT 線應力釋放夾1PETG黑~15 min
洞洞板立柱 ×22PETG黑~10 min

列印設定

設計要點

  1. 鉸鏈卡扣:盒體側面活頁鉤住配電箱門縫,開門檢查總開關時整盒隨門轉開,不用拆機。
  2. CT 出口防折角:CT 線出口做 R8 圓弧導槽——SCT-013 的線最常在出線口折斷,這是它唯一的死因。
  3. 散熱對流孔:底部進氣、頂部排氣各 4 孔,C6 發熱小但密閉盒內 OLED 怕熱。
  4. 標籤窗:正面留 40×15mm 卡紙窗,插一張「本盒監控中·勿拔」標籤,避免家人當成廢線拔掉。

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📦 相依:Arduino core for ESP32 ≥ 3.0(開發板選「ESP32C6 Dev Module」)、ArduinoJson ≥ 7.0、Adafruit SSD1306。
/* ============================================================
 *  ESP32-C6 非侵入式用電監控  |  Non-Intrusive Power Monitor
 *  ------------------------------------------------------------
 *  功能:CT 真功率 RMS + 今日/本月度數 + 待機小偷偵測 + 跳電警報
 *  硬體:ESP32-C6 DevKitC-1 + SCT-013(無電阻版)+10Ω 負載訊號鏈
 *        + SSD1306 OLED (I2C)
 *  供電:USB 5V 1A(配電箱旁检修插座)
 *  相依:Arduino core for ESP32 ≥3.0(板型 ESP32C6 Dev Module)
 *        ArduinoJson ≥7.0 / Adafruit_GFX + Adafruit_SSD1306
 *  安全:CT 只夾火線單芯;全機與市電零接觸,純磁耦合
 *  ============================================================ */

#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <esp_timer.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";

const float MAINS_V        = 110.0f;  // 台灣市電名壓(算功率用名義值即可,誤差 <2%)
const float CAL_K          = 1.06f;   // 校準係數:用電熱壺(純阻,標示功率)對表調出來的
const float IDLE_BASE_W    = 80.0f;   // 凌晨基底功率門檻:超過視為待機小偷
const float OUTAGE_W       = 8.0f;    // 低於此功率視為「跳電/停電」

/* ------------------------------------------------------------
 * 2. 腳位
 * ------------------------------------------------------------ */
const int PIN_CT     = 0;    // GPIO0 = ADC1_CH0(GPIO1 受 Wi-Fi 雜訊污染,禁用)
const int PIN_SDA    = 6;
const int PIN_SCL    = 7;

/* ------------------------------------------------------------
 * 3. 全域狀態
 * ------------------------------------------------------------ */
Preferences prefs;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);

volatile uint32_t adcSumSq = 0;   // ISR 累加的平方和(RMS 的核心)
volatile uint16_t adcCount = 0;
volatile int32_t  adcOffset = 0;  // 直流偏置補償(校準時量到的靜態中點)
float rmsCurrent = 0;             // 電流 RMS(A)
float watt = 0;                   // 功率(W,名壓近似)
float kwhToday = 0, kwhMonth = 0; // 度數累計
String todayKey = "";             // "YYYY-MM-DD" 跨日重置用
uint32_t lastSaveMs = 0, lastPushMs = 0, lastPollMs = 0;
uint32_t lowPowerSince = 0;       // 低功率持續計時(跳電判定)
bool outageAlerted = false;

/* ------------------------------------------------------------
 * ctISR —— 取樣中斷(10kHz)
 *   為什麼用 ISR 不用 loop 輪詢:50Hz 週期 20ms,loop 裡任何
 *   Wi-Fi/HTTP 延遲都會讓取樣不均,RMS 直接失真
 *   IRAM_ATTR:放進記憶體,避免 flash 快取中斷拖慢中斷本身
 * ------------------------------------------------------------ */
void IRAM_ATTR ctISR() {
  int32_t v = adcReadFast() - adcOffset;      // 減掉直流中點
  adcSumSq += (uint32_t)(v * v);              // 平方累加,開根號留到計算時
  adcCount++;
}

/* ------------------------------------------------------------
 * computeRms —— 每 100ms 把 ISR 累積值換算成功率
 *   100ms = 5 個完整 50Hz 週期:整週期取樣誤差最小
 * ------------------------------------------------------------ */
void computeRms() {
  noInterrupts();
  uint32_t s = adcSumSq; uint16_t n = adcCount;
  adcSumSq = 0; adcCount = 0;
  interrupts();
  if (n < 500) return;                        // 樣本不足(剛開機)不換算
  float rawRms = sqrtf((float)s / n);
  rmsCurrent = rawRms * 0.0011f * CAL_K;      // 0.0011 = 訊號鏈總增益反推(ADC LSB→A)
  watt = rmsCurrent * MAINS_V;                // 住宅功率因數 0.9~1.0,名義近似
}

/* ------------------------------------------------------------
 * accumulateEnergy —— 功率積分成度數
 * ------------------------------------------------------------ */
void accumulateEnergy() {
  static uint32_t lastMs = 0;
  float dtH = (millis() - lastMs) / 3600000.0f;
  lastMs = millis();
  if (dtH <= 0 || dtH > 0.1f) return;         // 逾時(停機重啟)不補算,防度數跳變
  kwhToday  += watt * dtH;
  kwhMonth  += watt * dtH;
}

/* ------------------------------------------------------------
 * tgApi / pushAlert —— Telegram 推播(同系列規範)
 * ------------------------------------------------------------ */
bool tgApi(const String& method, const String& body) {
  if (WiFi.status() != WL_CONNECTED) return false;
  HTTPClient http;
  http.begin(String("https://api.telegram.org/bot") + TG_BOT_TOKEN + "/" + method);
  http.addHeader("Content-Type", "application/json");
  int code = body.length() ? http.POST(body) : http.GET();
  http.end();
  return code == 200;
}
void pushAlert(const String& msg) {
  tgApi("sendMessage", String("{\"chat_id\":\"") + TG_CHAT_ID + "\",\"text\":\"" + msg + "\"}");
}

/* ------------------------------------------------------------
 * checkIdleBandit —— 凌晨 1~5 點基底功率檢查(每 10 分鐘)
 *   為什麼要「連三天」:單日高基底可能是隔天除濕機狂跑,
 *   連續三天才是常態性待機損耗
 * ------------------------------------------------------------ */
void checkIdleBandit() {
  static uint32_t lastCheck = 0;
  if (millis() - lastCheck < 600000UL) return;
  lastCheck = millis();
  struct tm t;
  if (!getLocalTime(&t, 500)) return;         // C6 無 RTC 電池,靠 SNTP
  if (t.tm_hour < 1 || t.tm_hour > 5) { prefs.putInt("idleDays", 0); return; }
  if (watt > IDLE_BASE_W) {
    int days = prefs.getInt("idleDays", 0) + 1;
    prefs.putInt("idleDays", days);
    if (days == 3) pushAlert(String("🕵️ 待機小偷:凌晨基底功率 ") + watt + " W 已連三天,建議逐回路關閉排查");
  } else prefs.putInt("idleDays", 0);
}

/* ------------------------------------------------------------
 * checkOutage —— 功率長時間歸零 = 跳電/停電
 *   滯遲:連續 60 秒低於門檻才警報,恢復供電自動解除旗標
 * ------------------------------------------------------------ */
void checkOutage() {
  if (watt < OUTAGE_W) {
    if (lowPowerSince == 0) lowPowerSince = millis();
    if (!outageAlerted && millis() - lowPowerSince > 60000UL) {
      outageAlerted = true;
      pushAlert("⚡ 功率已歸零 60 秒——可能跳電或停電(若你正在看這則代表只是 Wi-Fi 斷了)");
    }
  } else {
    lowPowerSince = 0;
    if (outageAlerted) { outageAlerted = false; pushAlert("✅ 供電恢復"); }
  }
}

/* ------------------------------------------------------------
 * saveEnergy —— 度數落盤 flash(每 5 分鐘)
 *   為什麼 5 分鐘:flash 抹寫壽命約 10 萬次,5 分鐘一次可用 340 年
 * ------------------------------------------------------------ */
void saveEnergy() {
  if (millis() - lastSaveMs < 300000UL) return;
  lastSaveMs = millis();
  prefs.putFloat("today", kwhToday);
  prefs.putFloat("month", kwhMonth);
}

/* ------------------------------------------------------------
 * rollDay —— 跨日重置今日度數(用 SNTP 日期做鍵)
 * ------------------------------------------------------------ */
void rollDay() {
  struct tm t;
  if (!getLocalTime(&t, 100)) return;
  char key[12];
  strftime(key, sizeof(key), "%F", &t);
  if (todayKey.length() && strcmp(key, todayKey.c_str()) != 0) {
    kwhToday = 0;
    pushAlert(String("📅 新的一天:昨日用電 ") + prefs.getFloat("today", 0) + " 度");
  }
  todayKey = key;
}

/* ------------------------------------------------------------
 * drawOled —— 大字功率 + 度數
 * ------------------------------------------------------------ */
void drawOled() {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(2);
  oled.setCursor(0, 0);
  oled.printf("%4.0fW", watt);
  oled.setTextSize(1);
  oled.setCursor(0, 34);
  oled.printf("Today : %.2f kWh", kwhToday);
  oled.setCursor(0, 48);
  oled.printf("Month : %.1f kWh  %s", kwhMonth, WiFi.status() == WL_CONNECTED ? "W" : "-");
  oled.display();
}

/* ------------------------------------------------------------
 * calibrateOffset —— 開機前 2 秒量直流中點
 *   為什麼:偏置電路永遠不會剛好 1.6500V,殘餘直流進平方項
 *   會讓 RMS 偏高(零電流時讀到 30W 的由來)
 * ------------------------------------------------------------ */
void calibrateOffset() {
  int64_t sum = 0;
  for (int i = 0; i < 2000; i++) { sum += analogReadMilliVolts(PIN_CT); delay(1); }
  adcOffset = (int32_t)(sum / 2000);
  Serial.printf("CT offset = %d mV\n", adcOffset);
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  analogSetPinAttenuation(PIN_CT, ADC_11db);  // 全量程 3.3V,預設衰減會削頂
  pinMode(PIN_CT, INPUT);
  calibrateOffset();

  esp_timer_create_args_t ta = {};
  ta.callback = [](void*){ ctISR(); };
  esp_timer_handle_t t;
  esp_timer_create(&ta, &t);
  esp_timer_start_periodic(t, 100);           // 10kHz:每週期 200 個樣本

  prefs.begin("pwrmon");
  kwhToday = prefs.getFloat("today", 0);
  kwhMonth = prefs.getFloat("month", 0);

  Wire.begin(PIN_SDA, PIN_SCL);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);

  configTime(8 * 3600, 0, "pool.ntp.org", "time.nist.gov");  // CST
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  for (int i = 0; i < 60 && WiFi.status() != WL_CONNECTED; i++) { delay(500); Serial.print("."); }
  Serial.println(WiFi.status() == WL_CONNECTED ? "\nWi-Fi OK (Wi-Fi 6!)" : "\nWi-Fi FAIL(本機計度數仍運作)");
  pushAlert("⚡ 用電監控重新上電");
}

void loop() {
  computeRms();
  accumulateEnergy();
  checkIdleBandit();
  checkOutage();
  saveEnergy();
  rollDay();
  if (millis() - lastPushMs > 3600000UL) {   // 每小時推播摘要
    lastPushMs = millis();
    pushAlert(String("⚡ 用電摘要:現值 ") + watt + " W|今日 " + kwhToday + " 度|本月 " + kwhMonth + " 度");
  }
  drawOled();
  delay(100);   // 與 100ms RMS 窗對齊;Wi-Fi 堆疊靠 idle task 跑
}

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 焊訊號鏈:洞洞板上焊 10Ω 負載、÷4 分壓、1.65V 偏置、隔直電容(圖表見說明書步驟 2)
  2. 列印零件:盒體、面板框、CT 線夾、洞洞板立柱(PETG)
  3. 總開關斷電,夾 CT:打開配電箱,找到主火線(黑),CT 夾上去,箭頭朝負載端
  4. 組裝盒體:主板、OLED、洞洞板入盒,CT 線走防折角出口
  5. 燒錄與空載校準:燒錄後看序列埠 offset 值;全屋斷電時功率應 < 5W
  6. 負載校準:開電熱壺(標示 800W),調 CAL_K 讓讀值落在 ±5% 內
  7. 掛箱收線:卡扣掛上配電箱門、貼「監控中勿拔」標籤、蓋回箱門

🔧 問題排除

問題原因解決方法
全屋斷電仍有 20~40W偏置中點殘餘直流進平方項確認 calibrateOffset() 在取樣啟動前執行;偏置電容漏電就換新
讀值是實際的 4 倍/0.25 倍分壓電阻焊錯(30k/10k 變 10k/10k)三用表量分壓比,改 CAL_K 或重焊
夾了主線永遠讀 0CT 夾到整束(黑+白同夾)只能夾火線單芯;箱內找不到就夾總開關下緣黑線本體
Wi-Fi 收得到但時斷時續配電箱鐵門衰減 + 邻居擁擠本專案選 C6 就是為這場景;再不行把盒體掛箱門外側(卡扣設計允許)
度數每天早上跳一點跨日重置前多算了 dt本程式 dt>0.1h 不補算已防呆;自改者注意停機期間不積分
凌晨誤報待機小偷除濕機夜間正常運轉把 IDLE_BASE_W 調高到 150W,或先拔掉除濕機觀察一週

🎯 進階延伸

📦 檔案清單

power-monitor/
├── src/
│   └── power-monitor.ino        # ESP32 程式碼(本頁完整程式)
├── stl/
│   ├── panel-box.stl            # 卡扣式盒體
│   ├── oled-frame.stl           # 面板框
│   ├── ct-strain-relief.stl     # CT 線應力夾
│   └── pcb-post.stl             # 洞洞板立柱
├── f3d/
│   └── power-monitor.f3d        # Fusion 360 原始檔
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32-S3 智慧車庫門控制器 📚 系列索引 下一篇:ESP32-C3 自行車轉向燈把手 →