📋 專案概述
今年 7 月電費單寄來 2,847 元——比隔壁租屋套房多了近一倍。我開始玩「插座式電力計」:把它插在冷氣上,測出來冷氣很乖;插在冰箱上,冰箱也很乖。問題是台電計度數減掉「我插過的那幾個」,每天還是憑空消失 2.1 度。插座式電力計一次只能看一個插座,永遠抓不出「總和對不起來」的兇手。
兇手藏在總開關後面:老冰箱老化發熱的封條、除濕機背後永遠亮著的待機燈、還有前一任房客接去陽台不知接了什麼的回路。要抓它,你需要的是一顆「看得到全屋」的眼睛——也就是夾在主線上的互感器(CT)。
查過市面上的方案:
- Shelly EM / 開閉所級 CT 監控:1,600~2,500 元,功能正確但——它假設你家有 Wi-Fi 覆蓋的配電箱、而且你願意讓資料經過廠商雲端。
- 中華電「智慧插座」類服務:月租型,一年租金夠買三顆本專案。
- 手動抄電表:免費,但你無法知道「凌晨 3 點誰在耗電」——而待機小偷恰恰只在凌晨耗電。
關鍵洞察:非侵入式(Non-Intrusive)是重點——CT 夾在火線外面就能測電流,不用斷電、不用拆線、不用電匠,租屋也裝得起來(搬走時取下來還原,配電箱毫髮無傷)。這個專案用 不到 300 元完成:
- 即時功率 + 今日/本月度數:RMS 演算法算真功率,不是「電壓×電流的謊言功率」
- 待機小偷偵測:凌晨 1–5 點基底功率 > 80W 連三天 → Telegram 點名
- 跳電警報:功率突然歸零 = 跳電/停電,推播告訴你(出差時最值錢的功能)
- 斷電不丢度數:每 5 分鐘把累計度數寫進 flash,停電一週回來度數照樣對
- Wi-Fi 6 首戰:全系列第一支 C6——配電箱鐵門一關,Wi-Fi 4 的 RSSI 常常只剩 -80dBm,Wi-Fi 6 的 OFDMA/BSS Coloring 實測救回 8dB
成本效益:台電夏季住宅 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-1 | 1 | ~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 ×2 | 1 組 | ~8 元 | 訊號鏈:CT→分壓→直流偏置 1.65V→ADC |
| 電源 | 5V 1A USB 電源 + 線 | 1 | ~40 元 | 配電箱旁通常有检修插座;沒有就拉延長線 |
| 顯示 | 0.96" SSD1306 OLED (I2C) | 1 | ~75 元 | 可選;不要再省 75 元,開機校準比較方便 |
| 合計 | ~313 元 | 不含 OLED 則 238 元 |
🔌 接線圖
詳細接線
| ESP32-C6 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO0(ADC1_CH0) | 訊號鏈輸出 | C6 的 ADC1 從 GPIO0 起;GPIO1 受 Wi-Fi 雜訊干擾,不要用 |
| GPIO6 / GPIO7 | OLED SDA / SCL | 軟體 I2C 任意腳,避開 strapping(GPIO8/9) |
| 3V3 / GND | OLED VCC / GND | OLED 與訊號鏈偏置都從 3V3 取,基準才一致 |
| 5V / GND | USB 供電 | 板子經 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 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 卡扣式盒體(配電箱外壁) | 1 | PETG | 灰 | ~2 hr |
| 面板框(OLED 視窗) | 1 | PETG | 灰 | ~40 min |
| CT 線應力釋放夾 | 1 | PETG | 黑 | ~15 min |
| 洞洞板立柱 ×2 | 2 | PETG | 黑 | ~10 min |
列印設定
- 材質:PETG——配電箱旁是室內最高溫的角落之一,且需要韌性抗衝擊
- 填滿:20% 直線;盒牆 3 圈
- 層厚:0.2mm
- 固定:背板預留 3M VHB 膠墊槽,配電箱鐵門不打孔(房東友善設計)
設計要點
- 鉸鏈卡扣:盒體側面活頁鉤住配電箱門縫,開門檢查總開關時整盒隨門轉開,不用拆機。
- CT 出口防折角:CT 線出口做 R8 圓弧導槽——SCT-013 的線最常在出線口折斷,這是它唯一的死因。
- 散熱對流孔:底部進氣、頂部排氣各 4 孔,C6 發熱小但密閉盒內 OLED 怕熱。
- 標籤窗:正面留 40×15mm 卡紙窗,插一張「本盒監控中·勿拔」標籤,避免家人當成廢線拔掉。
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📦 相依:Arduino core for ESP32 ≥ 3.0(開發板選「ESP32C6 Dev Module」)、ArduinoJson ≥ 7.0、Adafruit SSD1306。
📦 相依: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 組裝說明書
- 焊訊號鏈:洞洞板上焊 10Ω 負載、÷4 分壓、1.65V 偏置、隔直電容(圖表見說明書步驟 2)
- 列印零件:盒體、面板框、CT 線夾、洞洞板立柱(PETG)
- 總開關斷電,夾 CT:打開配電箱,找到主火線(黑),CT 夾上去,箭頭朝負載端
- 組裝盒體:主板、OLED、洞洞板入盒,CT 線走防折角出口
- 燒錄與空載校準:燒錄後看序列埠 offset 值;全屋斷電時功率應 < 5W
- 負載校準:開電熱壺(標示 800W),調
CAL_K讓讀值落在 ±5% 內 - 掛箱收線:卡扣掛上配電箱門、貼「監控中勿拔」標籤、蓋回箱門
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 全屋斷電仍有 20~40W | 偏置中點殘餘直流進平方項 | 確認 calibrateOffset() 在取樣啟動前執行;偏置電容漏電就換新 |
| 讀值是實際的 4 倍/0.25 倍 | 分壓電阻焊錯(30k/10k 變 10k/10k) | 三用表量分壓比,改 CAL_K 或重焊 |
| 夾了主線永遠讀 0 | CT 夾到整束(黑+白同夾) | 只能夾火線單芯;箱內找不到就夾總開關下緣黑線本體 |
| Wi-Fi 收得到但時斷時續 | 配電箱鐵門衰減 + 邻居擁擠 | 本專案選 C6 就是為這場景;再不行把盒體掛箱門外側(卡扣設計允許) |
| 度數每天早上跳一點 | 跨日重置前多算了 dt | 本程式 dt>0.1h 不補算已防呆;自改者注意停機期間不積分 |
| 凌晨誤報待機小偷 | 除濕機夜間正常運轉 | 把 IDLE_BASE_W 調高到 150W,或先拔掉除濕機觀察一週 |
🎯 進階延伸
- 回路級監控:再買 2~3 顆 SCT-013-050(小夾),分夾冷氣/冰箱/除濕回路——C6 只有一組 ADC,用 CD74HC4067 多工器輪流取樣
- Home Assistant:加 MQTT discovery,功率變成 energy 感測器,HA 的 Utility Meter 自動畫每日曲線
- 台電費率表:內建級距費率,推播直接顯示「今日電費 62 元」——把度數翻譯成錢,家人才有感
- 太陽能逆功率:CT 支援雙向,加裝逆功率偵測可看自家發電輸出(需補一個電壓基準過零偵測)
📦 檔案清單
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 # 說明文件