專案概述:一樓的窗,租來的門框

學弟租了內湖一栋一樓的老公寓。九月的某個凌晨,他被陽台氣密窗傳來的聲響嚇醒——一個男人在窗邊站了兩分鐘,因為室內燈突然亮了才走。報警之後學弟做的第一件事不是買監視器,而是想把每扇窗都裝上傳感器;第二件事是發現他是租的,門框窗框一顆螺絲都不能鑽。

市售選項他試了兩家。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 元,沒有訂閱、沒有雲端。

本專案功能清單:

為什麼選 ESP32-H2

候選無線待機判定
ESP32-C6Wi-Fi 6 + 802.15.4~7µA也能跑 Thread,但本專案零頻寬需求,H2 供電更簡單、價格更低
ESP32-C3Wi-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-1149原生 802.15.4;選 H2 就是選它的 radio
MC-38 磁簧開關115常開型,吸合導通;感測窗/門分離 <15mm
防拆微動開關(12×6mm)15殼體頂針式:殼離牆即斷
CR2450 電池 + SMD 貼片座各 125620mAh,3V 直供;比 CR2032 多 60% 容量
XC6206 3V LDO15備用:改雙 AA 供電時才焊上,直供方案不焊
綠色 LED + 100kΩ/1MΩ 電阻各 15狀態指示 + 電池分壓
殼體列印件 + 3M VHB + 標籤1 組46PLA 室內件;VHB 0.4mm 薄條
合計~150 元市售同規格網關型感測器:單點 690 元 + 網關 1,980 元
💡 前置條件(免費):家裡需要一台 Thread Border Router。最省事的組合是 Home Assistant Green/NAS + OpenThread Border Router add-on + 一支 SkyConnect/OTBR 棒(或任何 ESP32-C6/ODROID-9010 當 RCP)。十分鐘裝好,之後所有 Thread 裝置共用。沒有 HA 也行:樹莓派跑 otbr-agent 一條指令。

接線圖

ESP32-H2 DevKitC-1 3V3 ● G ● G3 ● G4 ● G6 ● ● 2 (ADC) CR2450 3V 直供 + → 3V3 − → GND MC-38 磁簧 常開・兩端 G3(內建上拉) 另一端 → GND 防拆微動 壓合=通 G4(內建上拉) 狀態 LED+100kΩ G6 → LED → GND 1MΩ 100kΩ G2(ADC)分壓測電池
接線圖:磁簧與防拆都接「腳→開關→GND」,靠內建上拉;CR2450 直接吃 3V3 腳
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 電壓
└─────────────────────────────┘
⚠️ 待機電流比預期高?兇手是板上的 USB-UART 晶片。3V3 直供時電流會倒灌進 CP2102,suspend 電流加起來可能吃掉 10~20µA。量到超過 20µA 就把板上 UART 晶片的 VCC 腳挑開(或改焊裸模组 H2-MINI-1)。本實測值 <20µA 是挑開 UART 後的數字。

3D 列印設計

列印件尺寸材質/設定設計重點
感測器主殼38×28×14mmPLA・壁 3 層・無填充支撐卡扣式上蓋;蓋內防拆頂針孔——殼一離牆,微動彈起斷路
磁鐵嵌座(門框側)24×12×8mmPLA・100% 填充3M 背膠免鑽孔;磁鐵埋深 1mm,對位公差 ±3mm
窗框滑軌夾依窗框 12〜20mmPLA・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 一輪完成
💡 為什麼用 HTTP 不用 HTTPS:Thread 端走 NAT64 出網,8000mAh 以下的電池撐不起 TLS 交握的重試成本;Telegram Bot API 的訊息內容本身已含 secret(token 在 URL path)。要更嚴謹可改走自家 HA 的 MQTT over Thread 本地端——推播由 HA 發,全程不出戶。

加入 Thread 網路(一次性)

  1. HA 裝 OpenThread Border Router add-on,插入 SkyConnect(設為 Thread 角色)。
  2. 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。
  3. 換回本專案韌體,開機自動載入 NVS dataset 續約。

組裝步驟

  1. 磁簧與防拆微動各焊兩腳到本體板(G3/G4 與 GND),熱熔膠固定感測頭。
  2. CR2450 座焊 +/−,先不裝電池,萬用表確認 3V3 對 GND 無短路。
  3. 入網測試(序列埠看 role=child)→ 開窗測試收推播 → 裝電池入殼。
  4. 主殼貼窗框(VHB 壓 30 秒),磁鐵嵌座對位貼窗扇——間隙 <12mm,頂針壓入。
  5. 72 小時觀察:誤報調冷卻、漏報查磁鐵間隙。
📘 每一步的卡榫方向、焊點位置、對位公差圖,見圖文版:《氣密窗保全感測器 組裝說明書》(IKEA 風格 6 步卡片,可 A4 列印帶到現場)。

問題排除

症狀原因解法
role 一直 detacheddataset 抄錯一個 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;重測待機

驗證結果

專案檔案

thread-door-sensor/
├── firmware/
│   └── thread_window_sensor.ino   # 本篇完整韌體
├── stl/
│   ├── sensor-case.stl            # 主殼(含防拆頂針孔)
│   ├── magnet-socket.stl          # 磁鐵嵌座
│   └── rail-clip.stl              # 窗框滑軌夾
├── manual/
│   └── assembly.html              # 圖文組裝說明書
└── README.md
← 上一篇:ESP32-S3 智慧包裹哨兵 📚 系列索引 下一篇:ESP32-C3 肌力訓練計數腕帶 →