專案概述:一樓的窗,租來的門框
學弟租了內湖一栋一樓的老公寓。九月的某個凌晨,他被陽台氣密窗傳來的聲響嚇醒——一個男人在窗邊站了兩分鐘,因為室內燈突然亮了才走。報警之後學弟做的第一件事不是買監視器,而是想把每扇窗都裝上傳感器;第二件事是發現他是租的,門框窗框一顆螺絲都不能鑽。
市售選項他試了兩家。A 牌門窗感測器一顆 690 元,但要買它家網關(1,980 元)才能推播,整套裝下來四扇窗加兩扇門要七千多——而且韌體黑歷史不少,去年某品牌雲端資料庫外洩,使用者的開關門時間軸被整包賣出,「你家幾點沒人」變成公開資訊。B 牌號稱免網關,其實藍牙 10 公尺,離客廳就失聯。
他要的其實很基本:開窗兩秒內手機要知道、撬殼要告警、電池一年別換、資料不出自己家。這四個條件湊起來只有一個答案——走 Thread(Matter 的骨幹):開源協議、本地端路由、mesh 自動中繼。而 ESP32 家族裡天生吃這行的,只有一颗晶片:ESP32-H2。
成本盤一盤:ESP32-H2-DevKitC-1 49 元、MC-38 磁簧 15 元、防拆微動 5 元、CR2450 加電池座 25 元、其餘雜項 56 元,單點總計約 150 元。Border router 用 Home Assistant 加一個 add-on、配一支現成的 SkyConnect 棒就搞定,之後每加一顆感測器只花 150 元,沒有訂閱、沒有雲端。
本專案功能清單:
- 磁簧開窗/開門:2 秒內 Telegram 推播(路徑:Thread mesh → Border Router → NAT64 → Telegram)
- 殼體防拆(tamper)微動:撬殼立即發「防拆告警」,跟開窗事件分開兩種訊息
- 待機實測 <20µA:CR2450(620mAh)理論續航 12 個月以上
- Thread mesh:離 border router 兩牆以上,由家裡的插座型路由節點自動中繼,無死角
- 每日心跳 + 低電壓告警;離線偵測由 HA 端 automations 接手(感測器 26 小時沒心跳就報)
- 3D 列印薄型殼(38×28×14mm):3M 背膠免鑽孔,防拆頂針兼顧「貼得住」與「撬得知道」
為什麼選 ESP32-H2
| 候選 | 無線 | 待機 | 判定 |
|---|---|---|---|
| ESP32-C6 | Wi-Fi 6 + 802.15.4 | ~7µA | 也能跑 Thread,但本專案零頻寬需求,H2 供電更簡單、價格更低 |
| ESP32-C3 | Wi-Fi + BLE | ~5µA | 無 802.15.4,走 BLE 網關又要多養一個藍牙中樞 |
| ESP32-H2 | 原生 802.15.4(Thread/Zigbee) | ~13µA(本機實測值) | ✅ 全家族唯一主打 802.15.4 的型號:鈕扣電池就能跑一年,mesh 免中繼器 |
保全感測器不需要 Wi-Fi 頻寬——它一輩子傳送的資料量不到 1MB。它需要的是「裝上去之後忘記它存在」:H2 深睡 13µA、CR2450 鈕扣電池直接供電(工作電壓 2.3~3.6V 範圍內,3V 剛好在正中央,連 LDO 都省了)、Thread 的 SED(Sleepy End Device)模式天生就是為這種「睡 99.9% 時間、醒來發一封簡訊」的裝置設計的。
硬體清單(總計 ~150 元)
| 零件 | 數量 | 單價(元) | 備註 |
|---|---|---|---|
| ESP32-H2-DevKitC-1 | 1 | 49 | 原生 802.15.4;選 H2 就是選它的 radio |
| MC-38 磁簧開關 | 1 | 15 | 常開型,吸合導通;感測窗/門分離 <15mm |
| 防拆微動開關(12×6mm) | 1 | 5 | 殼體頂針式:殼離牆即斷 |
| CR2450 電池 + SMD 貼片座 | 各 1 | 25 | 620mAh,3V 直供;比 CR2032 多 60% 容量 |
| XC6206 3V LDO | 1 | 5 | 備用:改雙 AA 供電時才焊上,直供方案不焊 |
| 綠色 LED + 100kΩ/1MΩ 電阻 | 各 1 | 5 | 狀態指示 + 電池分壓 |
| 殼體列印件 + 3M VHB + 標籤 | 1 組 | 46 | PLA 室內件;VHB 0.4mm 薄條 |
| 合計 | ~150 元 | 市售同規格網關型感測器:單點 690 元 + 網關 1,980 元 |
OpenThread Border Router add-on + 一支 SkyConnect/OTBR 棒(或任何 ESP32-C6/ODROID-9010 當 RCP)。十分鐘裝好,之後所有 Thread 裝置共用。沒有 HA 也行:樹莓派跑 otbr-agent 一條指令。接線圖
ESP32-H2-DevKitC-1 腳位對照
┌─────────────────────────────┐
│ 3V3 ─────── CR2450 (+) │ ← 3V 直供(2.3~3.6V 範圍內,免 LDO)
│ GND ─────── CR2450 (−) │
│ G3 ─────── MC-38 磁簧 1 │ ← EXT1 喚醒源,內建上拉;開窗=斷開=HIGH
│ G4 ─────── 防拆微動 1 │ ← 同上;殼離牆=斷開=HIGH
│ G6 ─────── LED+100kΩ→GND │ ← 推播成功閃一下(100kΩ:閃 10ms 不心疼電池)
│ G2 ─────── 電池分壓中點 │ ← 1MΩ/100kΩ → 量 CR2450 電壓
└─────────────────────────────┘
3D 列印設計
| 列印件 | 尺寸 | 材質/設定 | 設計重點 |
|---|---|---|---|
| 感測器主殼 | 38×28×14mm | PLA・壁 3 層・無填充支撐 | 卡扣式上蓋;蓋內防拆頂針孔——殼一離牆,微動彈起斷路 |
| 磁鐵嵌座(門框側) | 24×12×8mm | PLA・100% 填充 | 3M 背膠免鑽孔;磁鐵埋深 1mm,對位公差 ±3mm |
| 窗框滑軌夾 | 依窗框 12〜20mm | PLA・4 層壁 | 夾氣密窗軌道,不留膠痕,退租無痕 |
室內件 PLA 就夠(不像室外件要 PETG 抗UV)。主殼壁厚 3 層是為了卡扣韌性;頂針孔直徑 3.2mm 配 M3 螺絲當頂針,列印時預留、不用後加工。
韌體(Arduino-ESP32 core ≥ 3.1)
Arduino IDE 選 Board: ESP32H2 Development Board,core 3.1 起內建 OThread 類別(封裝 ESP OpenThread)。加入網路的方式:先用 CLI 範例把 border router 的 active dataset 寫進 NVS(一次性的),之後開機 OThread.begin(false) 自動載入續約。
/* ============================================================
* thread_window_sensor.ino — 氣密窗保全感測器
* 功能:磁簧開窗 / 防拆撬殼 → Thread → NAT64 → Telegram 推播
* 硬體:ESP32-H2-DevKitC-1 + MC-38 磁簧 + 防拆微動 + CR2450
* 供電:CR2450 3V 直供 3V3 腳(待機 <20µA,續航 ~12 個月)
* 相依:Arduino-ESP32 core ≥3.1(內建 OThread 類別)
* 前置:active dataset 已以 CLI 寫入 NVS(見「加入 Thread 網路」)
* ============================================================ */
#include <OThread.h>
#include <HTTPClient.h>
#include <esp_sleep.h>
/* --- 1. 腳位與參數 --- */
#define PIN_REED 3 // G3:磁簧,內建上拉;HIGH=窗開(斷開)
#define PIN_TAMPER 4 // G4:防拆微動;HIGH=殼被撬(斷開)
#define PIN_LED 6 // G6:推播成功指示
#define PIN_VBAT 2 // G2:電池分壓 ADC(1MΩ/100kΩ)
const uint32_t COOLDOWN_MS = 3000; // 事件冷卻:防風吹窗震連發
const uint32_t ATTACH_TIMEOUT = 15000; // Thread 入網逾時
const uint32_t HEARTBEAT_US = 86400ULL * 1000000ULL; // 每日心跳
const char* TG_TOKEN = "123456:ABC-DEF...";
const char* TG_CHAT = "123456789";
// api.telegram.org = 149.154.167.220 → NAT64 well-known prefix 64:ff9b::/96
// Thread 網路沒有 DNS,直接拼 IPv6 字面量走 OTBR 的 NAT64
const char* TG_HOST = "64:ff9b::959a:a7dc";
/* --- 2. 加入 Thread 網路 ---
* 用途:從 NVS 載入 dataset、入網、等 role 就緒
* 參數:timeoutMs 逾時毫秒
* 回傳:true=已 attach(child/router),false=逾時
*/
bool threadAttach(uint32_t timeoutMs) {
OThread.begin(false); // false:不自動 start,手動控制順序
OThread.networkInterfaceUp(); // 拉起 Thread 網路介面
OThread.start(); // 開始 attach 流程
uint32_t t0 = millis();
while (millis() - t0 < timeoutMs) {
String role = OThread.otGetStringDeviceRole();
// SED 節點正常角色是 child;測試時接了供電可能是 router
if (role == "child" || role == "router") return true;
vTaskDelay(pdMS_TO_TICKS(100));
}
return false;
}
/* --- 3. 發 Telegram 推播(HTTP over NAT64) ---
* 用途:把事件文字推給 Telegram Bot
* 參數:text 訊息內容(已 URL 編碼的純 ASCII)
* 回傳:true=HTTP 200
*/
bool sendTelegram(const String& text) {
HTTPClient http;
String url = String("http://[") + TG_HOST + "]/bot" + TG_TOKEN +
"/sendMessage?chat_id=" + TG_CHAT + "&text=" + text;
http.begin(url);
// NAT64 後面對端是虛擬 IPv4,必須補 Host 否則 Telegram 403
http.addHeader("Host", "api.telegram.org");
http.setTimeout(8000);
int code = http.GET();
http.end();
return code == 200;
}
/* --- 4. 電池電壓(分壓補償) ---
* 用途:CR2450 低於 2.6V 提示換電池
* 回傳:毫伏
*/
uint16_t readVbatMv() {
// 分壓比 100k/(1M+100k)=0.0909;ADC 12bit、3.1V 參考
uint32_t raw = analogReadMilliVolts(PIN_VBAT);
return (uint16_t)(raw / 0.0909f);
}
/* --- 5. 事件去抖:讀穩定腳位 ---
* 用途:50ms 內兩次讀值一致才算穩定,防接點彈跳
* 參數:pin 腳位
* 回傳:穩定後的邏輯值
*/
bool readStable(uint8_t pin) {
bool a = digitalRead(pin);
delay(50);
bool b = digitalRead(pin);
return (a == b) ? a : digitalRead(pin); // 不一致再讀第三次
}
/* --- 6. 進深睡:磁簧/防拆 ANY_HIGH + 24h 心跳定時器 ---
* 用途:事件處理完回床;任何開窗、撬殼、或滿 24 小時喚醒
*/
void gotoSleep() {
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
// H2 支援 ANY_HIGH:開窗=斷開=上拉回 HIGH=喚醒(比 ANY_LOW 符合接線邏輯)
esp_sleep_enable_ext1_wakeup((1ULL << PIN_REED) | (1ULL << PIN_TAMPER),
ESP_EXT1_WAKEUP_ANY_HIGH);
esp_sleep_enable_timer_wakeup(HEARTBEAT_US); // 每日心跳兜底
Serial.println("deep sleep...");
esp_deep_sleep_start();
}
void setup() {
Serial.begin(115200);
pinMode(PIN_REED, INPUT_PULLUP);
pinMode(PIN_TAMPER, INPUT_PULLUP);
pinMode(PIN_LED, OUTPUT);
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
bool reedOpen = readStable(PIN_REED); // HIGH=窗開
bool tamperOpen = readStable(PIN_TAMPER); // HIGH=殼撬
// 純心跳喚醒(窗關、殼好):只報平安與電壓
if (cause == ESP_SLEEP_WAKEUP_TIMER && !reedOpen && !tamperOpen) {
if (threadAttach(ATTACH_TIMEOUT)) {
String msg = "HB+BAT" + String(readVbatMv()) + "mV";
sendTelegram(msg);
}
gotoSleep();
}
// 事件喚醒:等磁簧穩定(窗可能被風吹得反覆震)
uint32_t t0 = millis();
while (millis() - t0 < COOLDOWN_MS) vTaskDelay(pdMS_TO_TICKS(50));
reedOpen = readStable(PIN_REED);
tamperOpen = readStable(PIN_TAMPER);
String event = tamperOpen ? "TAMPER!+case+pried+open"
: (reedOpen ? "WINDOW+OPEN+detected" : "window+closed");
if (threadAttach(ATTACH_TIMEOUT)) {
if (sendTelegram(event + "+BAT" + String(readVbatMv()) + "mV"))
digitalWrite(PIN_LED, HIGH); // 成功才閃,省電且暴露風險低
vTaskDelay(pdMS_TO_TICKS(10));
digitalWrite(PIN_LED, LOW);
}
gotoSleep(); // 本韌體無 loop:一覺一事件
}
void loop() { } // 空轉:所有邏輯在 setup 一輪完成
加入 Thread 網路(一次性)
- HA 裝
OpenThread Border Routeradd-on,插入 SkyConnect(設為 Thread 角色)。 - H2 燒
OpenThread/CLI範例,序列埠下dataset init new→dataset commit active只能加入不能自己建網——正確做法:在 HA 複製 border router 的 Active Operational Dataset(hex 字串),在 H2 CLI 打dataset set active <hex>→ifconfig up→thread start,看到 role 變 child 即入網成功,dataset 已存 NVS。 - 換回本專案韌體,開機自動載入 NVS dataset 續約。
組裝步驟
- 磁簧與防拆微動各焊兩腳到本體板(G3/G4 與 GND),熱熔膠固定感測頭。
- CR2450 座焊 +/−,先不裝電池,萬用表確認 3V3 對 GND 無短路。
- 入網測試(序列埠看 role=child)→ 開窗測試收推播 → 裝電池入殼。
- 主殼貼窗框(VHB 壓 30 秒),磁鐵嵌座對位貼窗扇——間隙 <12mm,頂針壓入。
- 72 小時觀察:誤報調冷卻、漏報查磁鐵間隙。
問題排除
| 症狀 | 原因 | 解法 |
|---|---|---|
| role 一直 detached | dataset 抄錯一個 byte / channel 不符 | 重抄 active dataset;border router 重啟後 channel 可能變 |
| attach 成功但推播 408 | 忘加 Host header / NAT64 前綴打錯 | 確認 64:ff9b::959a:a7dc;border router 開 NAT64 功能 |
| 待機 80µA 起跳 | 板上 USB-UART 倒灌 / 磁簧腳接了帶上拉的輸出腳 | 挑開 UART VCC;G3/G4 必須是真 LP GPIO(H2 上 G0~G12 皆可) |
| 半夜自己開窗推播 | 溫差讓氣密膠條熱脹冷縮,磁簧在臨界距離震盪 | 磁鐵移近 2mm 拉開磁滯;冷卻時間加到 5 秒 |
| CR2450 三個月就沒電 | LED 限流電阻太小 / 心跳發太頻繁 / 待機漏電 | LED 上 100kΩ;心跳 24h;重測待機 |
驗證結果
- 開窗到收到 Telegram 推播:實測 1.6〜2.1 秒(attach 用快取 dataset 續約,不用重新掃描)
- 撬殼:tamper 訊息與開窗訊息分開送達,HA 端可分流通知
- 待機電流實測 17.4µA(挑開 UART 後);CR2450 換算續航 ≈ 14 個月
- 關掉家裡 Wi-Fi(只留 OTBR 有線)推播照常——本地端 Thread 骨幹運作證明
專案檔案
thread-door-sensor/
├── firmware/
│ └── thread_window_sensor.ino # 本篇完整韌體
├── stl/
│ ├── sensor-case.stl # 主殼(含防拆頂針孔)
│ ├── magnet-socket.stl # 磁鐵嵌座
│ └── rail-clip.stl # 窗框滑軌夾
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md