📋 專案概述
去年冬天某個週六早上,我媽倒完垃圾站在車庫門口,隔著鐵門往裡看了三次——捲簾門到底有沒有關到底?她按了牆上的開門鍵、等門升到底、再按關門鍵、再等 25 秒,然後才安心去買菜。這個儀式每天至少重複兩次,而她真正想知道的只有一件事:「門現在是開著還是關著?」
更糟的是心理學部分:捲簾門的遙控器是「無狀態」的——按了之後你永遠不知道它有沒有收到。被偷過機車之後,我媽對「門有沒有關」的焦慮直接翻倍,一度考慮裝一支 3,000 元的監視器只為了「用手機看一眼門」。
查過市面上的車庫門控制器:
- GarageGadget / Q-Control 類:1,200~2,000 元,功能就是「手機開關門 + 狀態顯示」——跟我們要做的一模一樣,但貴五倍。
- 要 App 帳號 + 廠商雲端:廠商收攤那天,你家的門就「退網」了。車庫門這種 20 年家電,不該被一個 SaaS 綁架。
- 監視器方案:看得到門,但還是要走过去按,而且 24 小時串流吃頻寬。
關鍵洞察是:老式車庫門牆面板就是兩顆螺絲(BTN / COM),本質上一個乾接點。任何能「把這兩顆螺絲短路半秒」的東西都能開門——一個 30 元的繼電器就行。於是這個專案用 不到 340 元的零件 + 3D 列印壁掛盒完成:
- 雙磁簧判讀三狀態:全關 / 全開 / 移動中——不是「猜」,是門自己報給你聽
- Telegram 內聯鍵盤:📶 查狀態、⬆️ 開門、⬇️ 關門,按鈕按下去門就動
- 10 分鐘未關提醒:倒垃圾倒到忘記?手機先響。可選自動關門
- 斷電安全設計:重啟後進入 UNKNOWN,絕不盲目發脈衝——「寧可不開,不可亂開」
- OLED 本機直讀:手機不在身邊,抬頭看牆上也有答案
成本效益很殘酷:市售方案 1,500 元 vs 本專案 338 元,省下的錢夠把媽媽的機車重新鎖一遍。而真正的價值是那天早上,她不用再隔著鐵門看第三次。
為什麼選 ESP32-S3?
| 需求 | S3 的優勢 |
|---|---|
| 雙任務並行 | 雙核:Core 1 跑 Telegram 長輪詢(HTTPS 吃資源),Core 0 的迴圈專顧磁簧與繼電器,互不卡頓 |
| 未來加攝像頭 | 全系列唯一原生支援 DVP 攝像頭介面的型號——v2 加一顆 OV2640「親眼看門」,主板不用換 |
| RAM 充裕 | 512KB SRAM + 8MB PSRAM,HTTPS + ArduinoJson 同時開不心虛 |
| 體積不敏感 | 壁掛在車庫牆上、USB 供電,DevKitC-1 的體積完全不是問題 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-S3 DevKitC-1 N16R8 | 1 | ~165 元 | Lolin / Espressif 原廠板皆可 |
| 門狀態 | MC-5100 磁簧開關(常開) | 2 | ~10 元 | 一個裝「全關」位、一個裝「全開」位 |
| 門驅動 | 5V 單路繼電器模組(光耦、低觸發) | 1 | ~15 元 | 乾接點並聯牆面板 BTN/COM,不碰 110V |
| 顯示 | 0.96" SSD1306 OLED (I2C) | 1 | ~75 元 | 本機狀態直讀 |
| 提示 | 壓電蜂鳴器 5V | 1 | ~8 元 | 未關提醒嗶嗶 |
| 電源 | 5V 2A USB 電源 + 2m 線 | 1 | ~45 元 | 車庫插座常年供電 |
| 素材 | 端子、杜邦線、雙面膠 | 1 組 | ~10 元 | 磁簧走線建議 22AWG 以上 |
| 合計 | ~338 元 |
🔌 接線圖
詳細接線
| ESP32-S3 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO4(內部上拉) | 磁簧 A SIG(門框側,全關位置) | 門關到底時碰鐵 = LOW = 全關 |
| GPIO5(內部上拉) | 磁簧 B SIG(門框側,全開位置) | 門升到頂時碰鐵 = LOW = 全開 |
| GPIO6 | 繼電器模組 IN | 低觸發:高電位 = 斷開 = 安全態,上電預設拉高 |
| GPIO8 / GPIO9 | OLED SDA / SCL | I2C 0x3C,S3 可任意改腳,避開 GPIO19/20(USB) |
| GPIO15 | 蜂鳴器 +(− 接 GND) | 未關提醒;S3 GPIO15 非 strapping 腳,安全 |
| 5V / GND | 繼電器模組 VCC / GND | 繼電器線圈由板 5V 供電,吸合瞬間約 90mA |
ASCII 對照表(DevKitC-1 左排 / 右排)
┌─────────────────────┐
│ ESP32-S3 DevKitC-1 │
│ GPIO4 ○──┼── 磁簧A SIG ──┐
│ GPIO5 ○──┼── 磁簧B SIG ──┤ 磁簧另一端
│ GND ○──┴───────────────┴── 全部共地
│ GPIO6 ○──── 繼電器 IN
│ 5V ○──── 繼電器 VCC
│ GPIO8 ○──── OLED SDA
│ GPIO9 ○──── OLED SCL
│ GPIO15 ○─── 蜂鳴器 +
│ 3V3 ○──── OLED VCC
└─────────────────────┘
繼電器 OUT/COM ──並聯── 牆面板 BTN / COM 兩顆螺絲
⚠️ 牆面板後方就是 110V 馬達電源。斷電後再打開面板接線;繼電器 OUT 端只接 BTN/COM 訊號端子,任何一根線碰到 110V 端子都會銷毀整塊板子。沒把握就請水電工代勞 10 分鐘。
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 壁掛控制盒(底盤+上蓋) | 1 組 | PLA(直曬位改 PETG) | 灰 | ~3 hr |
| 磁簧支架 | 2 | PLA | 灰 | ~20 min |
| USB 線夾 | 2 | PLA | 黑 | ~10 min |
| 繼電器固定座 | 1 | PLA | 黑 | ~15 min |
列印設定
- 材質:PLA(車庫內非直曬);西晒車庫改 PETG(玻璃化溫度 80°C,夏天車庫 60°C 不軟)
- 填滿:15% 網格;壁掛孔周圍 3 圈實心
- 層厚:0.2mm,牆面安裝件對外觀要求低,0.28 也夠
- 壁厚:3 圈(2.4mm),螺絲孔 M3 直接鎖 PLA 可重複鎖 5 次以上
設計要點
- 繞行設計:控制盒內凹 20mm 深槽,整個罩在原牆面板外面——不拆原面板,房東也挑不出毛病
- 磁簧支架 6mm 間距規:支架自帶止擋,磁簧與門片鐵面的間距鎖死 6mm(MC-5100 觸發距離 10~15mm,留足對位誤差)
- 走線槽 + 防拉鉤:磁簧線沿門軌走到牆角,線夾每 50cm 一個,門升降不會勾到線
- 散熱柵 + 天線窗:盒頂 4×2mm 柵條;正面正對 S3 板載天線的位置開 30×10mm 透波窗,Wi-Fi 穿盒 RSSI 衰減 < 5dB
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📦 相依:Arduino core for ESP32 ≥ 3.0(開發板選「ESP32S3 Dev Module」)、ArduinoJson ≥ 7.0、Adafruit SSD1306。
📦 相依:Arduino core for ESP32 ≥ 3.0(開發板選「ESP32S3 Dev Module」)、ArduinoJson ≥ 7.0、Adafruit SSD1306。
/* ============================================================
* ESP32-S3 智慧車庫門控制器 | Smart Garage Door Controller
* ------------------------------------------------------------
* 功能:雙磁簧三態判讀 + Telegram 內聯鍵盤開關門 + 未關提醒
* 硬體:ESP32-S3 DevKitC-1 + MC-5100 磁簧×2 + 5V 低觸發繼電器
* + SSD1306 OLED (I2C 0x3C) + 壓電蜂鳴器
* 供電:USB-C 5V 2A(車庫插座常年供電)
* 相依:Arduino core for ESP32 ≥3.0(板型 ESP32S3 Dev Module)
* ArduinoJson ≥7.0 / Adafruit_GFX + Adafruit_SSD1306
* 安全:繼電器高電位=斷開=安全態;斷電重啟一律 UNKNOWN,
* 未確認狀態前絕不發脈衝
* ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
/* ------------------------------------------------------------
* 1. 使用者設定區 —— 全篇只有這段需要修改
* ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_SSID"; // 僅 2.4GHz(車庫通常只有 2.4G 訊號)
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TG_BOT_TOKEN = "YOUR_BOT_TOKEN"; // @BotFather 發行
const char* TG_CHAT_ID = "YOUR_CHAT_ID"; // 純數字 chat id
/* ------------------------------------------------------------
* 2. 腳位與時序參數
* ------------------------------------------------------------ */
const int PIN_REED_CLOSED = 4; // 磁簧A:門關到底時 LOW(內部上拉,斷線=HIGH=安全)
const int PIN_REED_OPEN = 5; // 磁簧B:門升到頂時 LOW
const int PIN_RELAY = 6; // 繼電器 IN:低觸發 → HIGH=斷開=安全態
const int PIN_BUZZER = 15; // 壓電蜂鳴器
const unsigned long RELAY_PULSE_MS = 500; // 脈衝寬度:夠牆面板去抖,又絕不鎖死閉合
const unsigned long MOVE_TIMEOUT_MS = 15000; // 捲簾門全程約 25 秒;15 秒無新端點 → 判定「移動中」
const unsigned long OPEN_ALERT_MS = 10UL * 60UL * 1000UL; // 開門 10 分鐘未關 → 提醒
const bool AUTO_CLOSE_ENABLE = false; // true = 提醒後再 5 分鐘自動關門(預設關,先觀察一個禮拜)
/* ------------------------------------------------------------
* 3. 門狀態機
* UNKNOWN ─ 斷電重啟的初始態:兩個磁簧都不信,先讀再說
* CLOSED ─ 磁簧A=LOW(關到底)
* OPEN ─ 磁簧B=LOW(升到頂)
* MOVING ─ 兩個都 HIGH:正在動,或停在行程中間
* ------------------------------------------------------------ */
enum DoorState { UNKNOWN, CLOSED, OPEN, MOVING };
DoorState doorState = UNKNOWN;
Preferences prefs;
bool autoClosePref = false; // NVS 持久化的自動關門偏好
bool openAlertSent = false; // 本輪開門已提醒旗標(關門後重置)
bool relayPulsing = false;
uint32_t relayOffAt = 0; // 繼電器預計釋放時刻(millis 計時,不用 delay 卡主迴圈)
uint32_t openSinceMs = 0; // 進入 OPEN 的時刻
uint32_t lastMoveMs = 0; // 上次「狀態還在變」的時刻,供 MOVING 超時收斂
uint32_t lastPollMs = 0; // Telegram 輪詢節拍
String lastCallbackId = ""; // 最近一次按鈕 callback id,必須回 answer 否則手機轉圈
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
/* ------------------------------------------------------------
* reedsStable —— 讀取兩路磁簧,50ms×3 多數表決防彈跳
* 參數:closed / open 輸出目前穩定態
* 回傳:true = 3 次讀值一致;false = 還在抖(維持舊值)
* 為什麼:門行至端點時鐵片掠過磁簧會抖動,單次讀值必誤判
* ------------------------------------------------------------ */
bool reedsStable(bool& closed, bool& open) {
bool c[3], o[3];
for (int i = 0; i < 3; i++) {
c[i] = digitalRead(PIN_REED_CLOSED) == LOW;
o[i] = digitalRead(PIN_REED_OPEN) == LOW;
delay(50);
}
closed = c[0] && c[1] && c[2]; // 多數表決:3 次全中才算數
open = o[0] && o[1] && o[2];
return (c[0] == c[1] && c[1] == c[2] && o[0] == o[1] && o[1] == o[2]);
}
/* ------------------------------------------------------------
* updateDoorState —— 磁簧 → 狀態機推進
* 回傳:狀態是否發生變化
* 規則:UNKNOWN 需穩定讀值才落地;端點之間的任何變化 = MOVING
* ------------------------------------------------------------ */
bool updateDoorState() {
bool c, o;
if (!reedsStable(c, o)) return false; // 抖動中 = 門正在通過端點 = 在動
DoorState next = doorState;
if (c) next = CLOSED;
else if (o) next = OPEN;
else if (doorState == CLOSED || doorState == OPEN || doorState == UNKNOWN)
next = MOVING; // 離開端點 → 進入移動中
// MOVING 超時收斂:停在行程中間(斷電/障礙反轉)不能永遠掛 MOVING
if (next == MOVING && millis() - lastMoveMs > MOVE_TIMEOUT_MS) next = UNKNOWN;
if (next != doorState) {
if (next != MOVING) lastMoveMs = millis();
if (next == OPEN) { openSinceMs = millis(); openAlertSent = false; }
if (next == CLOSED) openAlertSent = false; // 關好了,重置提醒旗標
doorState = next;
drawOled();
return true;
}
return false;
}
/* ------------------------------------------------------------
* pulseRelay —— 對牆面板發一個 500ms 脈衝(非阻塞)
* 為什麼非阻塞:delay(500) 期間無法收 Telegram 也無法更新 OLED;
* 更重要的是 delay 期間若當機,繼電器可能卡在閉合態 = 門一直動
* ------------------------------------------------------------ */
void pulseRelay() {
if (relayPulsing) return; // 脈衝中不重入(Telegram 連點防呆)
digitalWrite(PIN_RELAY, LOW); // LOW = 吸合 = 模擬「按下牆鍵」
relayOffAt = millis() + RELAY_PULSE_MS;
relayPulsing = true;
}
void tickRelay() {
if (relayPulsing && millis() >= relayOffAt) {
digitalWrite(PIN_RELAY, HIGH); // 時間到強制釋放,無論發生什麼事
relayPulsing = false;
}
}
/* ------------------------------------------------------------
* tgApi —— 呼叫 Telegram Bot API(POST)
* 參數:method = API 方法名;jsonBody = 請求體(可為空字串)
* 回傳:HTTP 200 為 true
* ------------------------------------------------------------ */
bool tgApi(const String& method, const String& jsonBody) {
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 = jsonBody.length() ? http.POST(jsonBody) : http.GET();
bool ok = (code == 200);
http.end(); // S3 RAM 多,但 HTTPS 連線物件仍要手動釋放
return ok;
}
/* ------------------------------------------------------------
* sendStatus —— 推送狀態訊息 + 內聯鍵盤(開門/關門/查詢)
* ------------------------------------------------------------ */
void sendStatus(const String& note) {
const char* icon = doorState == CLOSED ? "🔒 全關"
: doorState == OPEN ? "🔓 全開"
: doorState == MOVING ? "🚧 移動中"
: "❓ 未知(請人工確認)";
String text = String("🚪 車庫門:") + icon + (note.length() ? "\n" + note : "");
String kb = "{\"inline_keyboard\":[["
"{\"text\":\"⬆️ 開門\",\"callback_data\":\"OPEN\"},"
"{\"text\":\"⬇️ 關門\",\"callback_data\":\"CLOSE\"}],"
"[{\"text\":\"📶 查詢\",\"callback_data\":\"STATUS\"}]]}";
String body = String("{\"chat_id\":\"") + TG_CHAT_ID +
"\",\"text\":\"" + text + "\",\"reply_markup\":" + kb + "}";
tgApi("sendMessage", body);
}
/* ------------------------------------------------------------
* answerCallback —— 回覆 callback_query
* 為什麼必須:不回 answerCallbackQuery,手機端按鈕會永遠轉圈
* ------------------------------------------------------------ */
void answerCallback(const String& id) {
tgApi("answerCallbackQuery", String("{\"callback_id\":\"") + id + "\"}");
}
/* ------------------------------------------------------------
* pollTelegram —— 長輪詢 getUpdates,解析按鈕回調
* 節拍 2 秒;只認自己 chat id 的訊息(防別人按你的門)
* ------------------------------------------------------------ */
void pollTelegram() {
if (millis() - lastPollMs < 2000) return;
lastPollMs = millis();
if (WiFi.status() != WL_CONNECTED) return;
HTTPClient http;
http.begin(String("https://api.telegram.org/bot") + TG_BOT_TOKEN + "/getUpdates?timeout=1");
String resp;
int code = http.GET();
if (code == 200) resp = http.getString();
http.end();
if (resp.length() == 0) return;
JsonDocument doc;
if (deserializeJson(doc, resp)) return; // 解析失敗直接丟棄
static long updateOffset = 0;
for (JsonObject upd : doc["result"].as<JsonArray>()) {
long id = upd["update_id"] | 0L;
if (id <= updateOffset) continue; // 已處理過的舊訊息不重放
updateOffset = id;
JsonVariant cb = upd["callback_query"];
if (cb.isNull()) continue;
if (String(cb["message"]["chat"]["id"]).indexOf(TG_CHAT_ID) < 0) {
answerCallback(cb["id"].as<String>()); // 陌生人的按鈕:回掉但不執行
continue;
}
String data = cb["data"] | "";
String cid = cb["id"] | "";
answerCallback(cid);
if (data == "OPEN") {
if (doorState == CLOSED) { pulseRelay(); sendStatus("已發送開門脈衝"); }
else sendStatus("目前非全關狀態,為安全檔下拒絕開門");
} else if (data == "CLOSE") {
if (doorState == OPEN) { pulseRelay(); sendStatus("已發送關門脈衝"); }
else sendStatus("目前非全開狀態,拒絕關門(門可能在中間)");
} else {
sendStatus("");
}
}
}
/* ------------------------------------------------------------
* checkOpenTimeout —— 開門逾時提醒 / 自動關門
* ------------------------------------------------------------ */
void checkOpenTimeout() {
if (doorState != OPEN) return;
uint32_t held = millis() - openSinceMs;
if (!openAlertSent && held > OPEN_ALERT_MS) {
openAlertSent = true;
tone(PIN_BUZZER, 2200, 600);
sendStatus(AUTO_CLOSE_ENABLE ? "已自動關門" : "開太久了!請確認是否手動關閉");
if (AUTO_CLOSE_ENABLE) pulseRelay();
}
}
/* ------------------------------------------------------------
* drawOled —— 本機狀態直讀(大字 + 計數)
* ------------------------------------------------------------ */
void drawOled() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(2);
oled.setCursor(0, 0);
oled.print(doorState == CLOSED ? "CLOSED" :
doorState == OPEN ? "OPEN " :
doorState == MOVING ? "MOVING" : "??");
oled.setTextSize(1);
oled.setCursor(0, 32);
oled.printf("Cycles: %d AutoClose:%s\n",
prefs.getInt("cycles", 0), autoClosePref ? "ON" : "OFF");
oled.printf("WiFi: %s", WiFi.status() == WL_CONNECTED ? "OK" : "DOWN");
oled.display();
}
/* ------------------------------------------------------------
* setup —— 先寫後設:GPIO6 先拉高再設輸出,
* 避免上電瞬間浮動毛刺讓低觸發繼電器「嗑」一下
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
delay(1000);
digitalWrite(PIN_RELAY, HIGH); // 先寫安全態
pinMode(PIN_RELAY, OUTPUT); // 再設輸出(順序不可顛倒)
pinMode(PIN_REED_CLOSED, INPUT_PULLUP);
pinMode(PIN_REED_OPEN, INPUT_PULLUP);
pinMode(PIN_BUZZER, OUTPUT);
digitalWrite(PIN_BUZZER, LOW);
prefs.begin("gadoor");
autoClosePref = prefs.getBool("autoclose", false);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
oled.display();
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" : "\nWi-Fi FAIL(本機 OLED 仍可用)");
tone(PIN_BUZZER, 1500, 120);
updateDoorState(); // 開機先讀一次磁簧落地狀態
sendStatus("控制器重新上電"); // 斷電重啟通知:讓你知道它回來了
}
/* ------------------------------------------------------------
* loop —— 非阻塞四件事:繼電器節拍 / 狀態推進 / 逾時 / 輪詢
* ------------------------------------------------------------ */
void loop() {
tickRelay();
if (updateDoorState()) prefs.putInt("cycles", prefs.getInt("cycles", 0) + 1);
checkOpenTimeout();
pollTelegram();
delay(20); // 讓出 CPU 給 Wi-Fi 堆疊;20ms 對門控綽綽有餘
}
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 列印零件:控制盒、磁簧支架×2、線夾×2;直曬車庫改 PETG
- 安裝磁簧:門框上(全關位)與門軌上端(全開位)各鎖一支,支架止擋保證 6mm 間距
- 斷電接牆面板:關閉車庫門電源,開面板將繼電器 OUT 兩線並聯到 BTN / COM 螺絲
- 接主板:磁簧→GPIO4/5、繼電器 IN→GPIO6、OLED→GPIO8/9、蜂鳴器→GPIO15
- 燒錄與設定:Arduino IDE 選 ESP32S3 Dev Module,填入 Wi-Fi 與 Bot Token 後燒錄
- 三狀態驗證:手動開門→OLED 應顯示 MOVING→OPEN;關門→CLOSED。任何一步不對先查磁簧
- 掛盒收線:主板與繼電器入盒、線夾固定走線、盒體鎖牆、上電
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 門動作了,狀態卻沒變 | 磁簧與鐵片間距 > 15mm 或裝反側 | 用支架止擋重對位;門片側要有鐵(鋁門片需貼附隨附鐵片) |
| 上電瞬間門自己動了一下 | GPIO 初始化順序錯誤,毛刺觸發低觸發繼電器 | 照本程式 先 digitalWrite 再 pinMode;或改高觸發模組 |
| Telegram 按鈕一直轉圈 | 沒回 answerCallbackQuery | 本程式已內建;自改程式時記得每個 callback_id 都要回 |
| 狀態一直卡在 MOVING | 門停在行程中間(斷電/障礙反轉) | 15 秒後自動收斂為 UNKNOWN,人工用遙控器把門歸位 |
| Wi-Fi 收線正常但連不上 | 車庫只有 5GHz 中繼或 RSSI 太弱 | S3 也只支援 2.4GHz;加一支 90 元中繼器到車庫 |
| OLED 沒畫面 | 實為 0x3D 地址 | oled.begin(..., 0x3D);或先跑 I2C Scanner |
🎯 進階延伸
- 攝像頭確認(S3 限定):加 OV2640(~90 元),關門後拍一張存 SD,「門關了」升級成「門關了,這是照片」——這正是選 S3 的伏筆
- Home Assistant:改 MQTT discovery,車庫門變成 HA 的 cover 實體,可排程「凌晨 2 點未關自動關」
- 太陽能化:車庫外牆裝 6V 板 + 18650,deep sleep + 磁簧喚醒(S3 GPIO 支援 deep sleep wake,待機可壓到 < 100µA)
- 多人分權:白名單 chat id 陣列,爸媽各自的 Bot 對話都能開門,陌生人按了只回「你不是這家的」
📦 檔案清單
garage-door/
├── src/
│ └── garage-door.ino # ESP32 程式碼(本頁完整程式)
├── stl/
│ ├── wall-box-bottom.stl # 壁掛盒底盤
│ ├── wall-box-top.stl # 壁掛盒上蓋
│ ├── reed-bracket.stl # 磁簧支架(含 6mm 止擋)
│ └── cable-clip.stl # USB/磁簧線夾
├── f3d/
│ └── garage-door.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件