📋 專案概述

去年冬天某個週六早上,我媽倒完垃圾站在車庫門口,隔著鐵門往裡看了三次——捲簾門到底有沒有關到底?她按了牆上的開門鍵、等門升到底、再按關門鍵、再等 25 秒,然後才安心去買菜。這個儀式每天至少重複兩次,而她真正想知道的只有一件事:「門現在是開著還是關著?」

更糟的是心理學部分:捲簾門的遙控器是「無狀態」的——按了之後你永遠不知道它有沒有收到。被偷過機車之後,我媽對「門有沒有關」的焦慮直接翻倍,一度考慮裝一支 3,000 元的監視器只為了「用手機看一眼門」。

查過市面上的車庫門控制器:

  1. GarageGadget / Q-Control 類:1,200~2,000 元,功能就是「手機開關門 + 狀態顯示」——跟我們要做的一模一樣,但貴五倍。
  2. 要 App 帳號 + 廠商雲端:廠商收攤那天,你家的門就「退網」了。車庫門這種 20 年家電,不該被一個 SaaS 綁架。
  3. 監視器方案:看得到門,但還是要走过去按,而且 24 小時串流吃頻寬。

關鍵洞察是:老式車庫門牆面板就是兩顆螺絲(BTN / COM),本質上一個乾接點。任何能「把這兩顆螺絲短路半秒」的東西都能開門——一個 30 元的繼電器就行。於是這個專案用 不到 340 元的零件 + 3D 列印壁掛盒完成:

成本效益很殘酷:市售方案 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 N16R81~165 元Lolin / Espressif 原廠板皆可
門狀態MC-5100 磁簧開關(常開)2~10 元一個裝「全關」位、一個裝「全開」位
門驅動5V 單路繼電器模組(光耦、低觸發)1~15 元乾接點並聯牆面板 BTN/COM,不碰 110V
顯示0.96" SSD1306 OLED (I2C)1~75 元本機狀態直讀
提示壓電蜂鳴器 5V1~8 元未關提醒嗶嗶
電源5V 2A USB 電源 + 2m 線1~45 元車庫插座常年供電
素材端子、杜邦線、雙面膠1 組~10 元磁簧走線建議 22AWG 以上
合計~338 元

🔌 接線圖

ESP32-S3 DevKitC-1 N16R8 USB-C(供電+燒錄) GPIO4 GPIO5 GPIO6 GPIO8/9 磁簧 A(全關位) SIG ── ┤├ GND 磁簧 B(全開位) SIG ── ┤├ GND 5V 繼電器模組 IN VCC / GND OUT→BTN/COM OLED 0.96" SDA/SCL/VCC/GND 黃線 GPIO4 → 磁簧A SIG(磁簧另一端接 GND) 黃線 GPIO5 → 磁簧B SIG(另一端 GND) 紅線 GPIO6 → IN(低觸發) 綠線 GPIO8/9 → SDA/SCL(VCC=3V3、GND 共地)
▲ 磁簧採「對 GND 導通」接法,主板內部上拉——線被剪斷 = 斷開 = 安全狀態。繼電器 OUT 只是開關,並聯到牆面板螺絲即可。

詳細接線

ESP32-S3 腳位連接說明
GPIO4(內部上拉)磁簧 A SIG(門框側,全關位置)門關到底時碰鐵 = LOW = 全關
GPIO5(內部上拉)磁簧 B SIG(門框側,全開位置)門升到頂時碰鐵 = LOW = 全開
GPIO6繼電器模組 IN低觸發:高電位 = 斷開 = 安全態,上電預設拉高
GPIO8 / GPIO9OLED SDA / SCLI2C 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
磁簧支架2PLA灰~20 min
USB 線夾2PLA黑~10 min
繼電器固定座1PLA黑~15 min

列印設定

設計要點

  1. 繞行設計:控制盒內凹 20mm 深槽,整個罩在原牆面板外面——不拆原面板,房東也挑不出毛病
  2. 磁簧支架 6mm 間距規:支架自帶止擋,磁簧與門片鐵面的間距鎖死 6mm(MC-5100 觸發距離 10~15mm,留足對位誤差)
  3. 走線槽 + 防拉鉤:磁簧線沿門軌走到牆角,線夾每 50cm 一個,門升降不會勾到線
  4. 散熱柵 + 天線窗:盒頂 4×2mm 柵條;正面正對 S3 板載天線的位置開 30×10mm 透波窗,Wi-Fi 穿盒 RSSI 衰減 < 5dB

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📦 相依: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 組裝說明書
  1. 列印零件:控制盒、磁簧支架×2、線夾×2;直曬車庫改 PETG
  2. 安裝磁簧:門框上(全關位)與門軌上端(全開位)各鎖一支,支架止擋保證 6mm 間距
  3. 斷電接牆面板:關閉車庫門電源,開面板將繼電器 OUT 兩線並聯到 BTN / COM 螺絲
  4. 接主板:磁簧→GPIO4/5、繼電器 IN→GPIO6、OLED→GPIO8/9、蜂鳴器→GPIO15
  5. 燒錄與設定:Arduino IDE 選 ESP32S3 Dev Module,填入 Wi-Fi 與 Bot Token 後燒錄
  6. 三狀態驗證:手動開門→OLED 應顯示 MOVING→OPEN;關門→CLOSED。任何一步不對先查磁簧
  7. 掛盒收線:主板與繼電器入盒、線夾固定走線、盒體鎖牆、上電

🔧 問題排除

問題原因解決方法
門動作了,狀態卻沒變磁簧與鐵片間距 > 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

🎯 進階延伸

📦 檔案清單

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                    # 說明文件
← 上一篇:ESP32-C3 肌力訓練計數腕帶 📚 系列索引 下一篇:ESP32-C6 非侵入式用電監控 →