ESP32 全系列 × 3D 列印 · 57/59

ESP32-C3 爬蟲恆溫守護者:雙迴路溫控+過熱斷電

📅 2026-11-08 · 🏷️ 數位科技 · 📂 ESP32 全系列 × 3D 列印系列 · 57/59 · ⏱️ 實作約 3 小時(不含列印)

📋 專案概述

2026 年 1 月寒流夜,我姊的鬃獅蜥「阿旺」在 12°C 的陽臺上撐了一整晚——她的定溫加熱墊插上後就再也沒斷過電,感溫頭早被墊子壓到脫落。

獸醫診治加保溫燈重買花了 NT$2,600,阿旺撿回一條命,但代謝失調養了兩個月。她氣呼呼地問我:「爬蟲專用恆溫器不是號稱會斷電嗎?」拆開那顆 NT$350 的類比恆溫器,裡面是一片雙金屬片開關——簧片Fatigue 後觸點黏死,燈就一直燒。

爬蟲不是貓狗,溫度就是它的生命線:鬃獅蜥曬背點要 35–38°C、冷區 24–29°C、夜間不低於 20°C,超過 45°C 則會熱死(PetSmart 爬寵照護指南、WSU 獸醫學院)。控錯方向的代價比暖氣失控更直接。

市售方案價格問題
類比雙金屬恆溫器NT$300–400±3°C 漂移、簧片壽命短、無告警——寒流夜那顆就是它
Vivarium Electronics VE-100 數位款NT$1,500+(含探頭)單通道;曬燈、夜陶兩路要買兩顆
智能家居溫控插座+普通探頭NT$700–900探頭擺空氣中測不到曬背點表面溫度;雲端斷線就失聯
本專案≈ NT$690雙通道、表面溫度直測、韌體+硬體兩層過熱保護、斷電與過熱 Telegram 告警

一隻品相正常的鬃獅蜥 NT$3,500–8,000,爬蟲急診一次 NT$1,200 起跳。這套系統把「單一探頭+單一判斷」拆成三探頭交叉驗證加兩層斷電,NT$690 買的是不用半夜驚醒的權利。

功能清單

為什麼選 ESP32-C3:這顆裝在牆上常年插電,功耗毫無意義——省電 cores(C2/H2)在這裡沒有優勢。C3 SuperMini 是全家最便宜的 Wi-Fi 板(約 90 元,twarm 焊好排針版 210 元,2026-11 網拍實價),USB-C 供電調試一條線、15 個 GPIO 綽綽有餘,且第 56 篇已驗證它的深睡與閃存穩定性。同價位沒有第二個 Wi-Fi 選項。

🧱 硬體清單

元件數量單價備註
ESP32-C3 SuperMini190 元網拍 77 元起、含排針 210 元(twarm);本清單取中間值
DS18B20 防水探針(1M 線)260 元規格:±0.5°C(−10~85°C)、12-bit(Adafruit #381 同型)
DHT22(AM2302)溫濕度1245 元機器人王國 NT$245;±0.5°C、±2%RH
0.96" OLED SSD1306 I2C1130 元地址 0x3C;網拍行價,參考 US$4–6
2 通道光耦隔離繼電器模組(5V)190 元3.3V 可觸發;10A 250VAC 觸點
KSD9700 溫控開關 45°C 常閉245 元硬體備援,鎖在燈座散熱片旁
5V 2A USB 變壓器+短線1100 元優質品牌;劣質手機充電頭會因繼電器雜訊當機
端子臺、 fuse 座+2A 慢斷保險絲、3 芯電源線1 組90 元燈座請買現成附開關燈線改接,不要裸接螺絲端子
4.7kΩ 電阻(1-Wire 上拉)11 元兩支探針共匯流排
總計≈ NT$906單燈單探頭精簡版可壓到 ≈ NT$590

價格為 2026 年 11 月臺灣網拍/供應商實價(twarm、機器人王國、Adafruit 官網),其餘為蝦皮行情估值,實際以當日報價為準。

強電警告:本專案涉及 110V 市電。接線前務必拔掉插頭、用電表確認無電再動工;所有市電接點必須藏在封閉端子盒內,爬蟲箱旁的小孩與水都是風險源。沒碰過強電就請水電工接手這 20 分鐘的活,NT$1,000 買命不貴。

🔌 接線圖(ESP32-C3 SuperMini 腳位)

ESP32-C3 SuperMini × 雙迴路恆溫 接線圖 ESP32-C3 SuperMini 5VGNDGPIO1GPIO4GPIO5GPIO6GPIO7GPIO10 3V3GPIO0GPIO2 2ch 光耦繼電器模組 IN1 ← GPIO10(白天曬背燈) IN2 ← GPIO1(夜間陶熱器) VCC ← 5V GND ← GND NO 端 → 燈座(串 KSD9700) DS18B20 防水探針 ×2(1-Wire) #1 壓在曬背板上(表面溫) #2 冷區陰影處(環境溫) DQ 共線+4.7kΩ 上拉至 3V3 GPIO4 · 黃 DHT22 溫濕度(模組自帶上拉) DATA ← GPIO5 VCC ← 3V3 OLED 0.96" SSD1306(I2C 0x3C) SDA ← GPIO6 SCL ← GPIO7 VCC ← 3V3 GPIO0:模式按鈕(GND 觸發,內部上拉) 強電側:插頭→保險絲→KSD9700→燈座,全程封閉端子盒
圖 1:接線圖(自製 SVG)。兩支 DS18B20 共用 GPIO4 匯流排,靠 64-bit ROM 序號區分。
ESP32-C3 腳位連接線色備註
GPIO4DS18B20 ×2 DQ黃4.7kΩ 上拉至 3V3,兩支共線
GPIO5DHT22 DATA紫模組版自帶 10k 上拉
GPIO6 / GPIO7OLED SDA / SCL青 / 綠I2C 0x3C
GPIO10 / GPIO1RELAY1 / RELAY2 IN紅 / 粉光耦模組低準位觸發亦可(程式內反相)
GPIO0模式按鈕白對 GND 短接,啟用內部上拉
5V / 3V3 / GND繼電器 VCC / 感測器電源橙 / 紅 / 黑感測器勿從 5V 取電(DHT22 3.3V 準位相容)
接地共地:DS18B20 探針拉 1M 長線沿鐵網走線,務必與繼電器、燈線分槽。若偶爾出現 −127 誤判,先在探針端並聯 0.1µF 陶瓷電容,再懷疑斷線。

🖨️ 3D 列印設計

零件數量材質設定
主控牆掛盒(76×52×28mm,OLED 視窗+按鈕孔)1PETG0.2mm、25% 陀螺_fill、壁 3 圈
探針應變消除夾(束帶孔,夾住曬背板緣)2PETG0.2mm、40%、壁 3 圈
KSD9700 燈座固定片(M3 銅嵌件鎖燈殼散熱片)2PETG0.2mm、50%(導熱貼實)
箱緣走線夾(12mm 以內玻璃/木框)4PETG0.28mm、20%
材質鐵律:燈源半徑 30cm 內禁用 PLA。PLA 玻璃轉移溫度約 60°C,曬背燈輻射下會軟化蠕變——探針夾一旦下垂,量到的就不再是曬背板溫度,整套控制跟著失真。PETG 耐熱約 75°C、ASA 更佳,本專案全部 PETG。

設計重點

💻 Arduino 韌體

先讀:📖 圖文組裝說明書(7 步驟 →)。開發板選「ESP32C3 Dev Module」,USB CDC 開啟。強電步驟請先只插 USB、兩路燈完全不接,用探針摩擦生熱驗證邏輯後才接燈。
/* ============================================================
 * 爬蟲恆溫守護者 v1.0
 * 功能:雙迴路控溫(白天曬背燈/夜間陶熱器)+三探頭交叉驗證
 *       +42°C 韌體硬截斷+探頭斷線保護+斷電回覆+Telegram 告警
 * 硬體:ESP32-C3 SuperMini、DS18B20 防水探針×2(1-Wire 共線)、
 *       DHT22、SSD1306 0.96"、2ch 光耦繼電器(高準位觸發)
 * 供電:USB-C 5V(繼電器 5V/感測器 3V3);燈具側另串 KSD9700 45°C
 * 相依:DallasTemperature 3.11、Adafruit Unified Sensor 1.1、
 *       Adafruit DHT sensor library 2.2、ESP8266 and ESP32 OLED driver
 *       for SSD1306(Adafruit_SSD1306 2.6)
 * ============================================================ */
#include <WiFi.h>
#include <WiFiUdp.h>
#include <time.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <DHT.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>

/* ---------- 1. 使用者設定 ---------- */
const char* WIFI_SSID  = "家裡 2.4G SSID";
const char* WIFI_PASS  = "xxxxxxxx";
const char* TG_TOKEN   = "123456:ABC-xxxxxxxx";   // @BotFather
const char* TG_CHAT    = "123456789";             // @userinfobot 查 ID
const long  UTC_OFFSET_SEC = 8 * 3600;            // UTC+8

/* 控溫參數(°C)——依 PetSmart 照護指南:曬背 35–38 / 冷區 24–29 / 夜 ≥20 */
const float BASK_ON = 35.5, BASK_OFF = 37.5;      // 通道 A:曬背板表面
const float NIGHT_ON = 20.0, NIGHT_OFF = 21.5;    // 通道 B:夜間冷區
const float ABS_MAX = 42.0, ABS_RECOVER = 38.0;   // 韌體硬截斷/回滯恢復
const uint32_t MIN_SWITCH_MS = 60000;             // 同通道最短開/關間隔
const uint8_t LIGHT_ON_H = 7, LIGHT_ON_M = 30;    // 照明 07:30
const uint8_t LIGHT_OFF_H = 19, LIGHT_OFF_M = 30; // 熄燈 19:30

/* ---------- 2. 腳位 ---------- */
#define PIN_1WIRE   4
#define PIN_DHT     5
#define PIN_RELAY_A 10
#define PIN_RELAY_B 1
#define PIN_BTN     0
#define OLED_SDA    6
#define OLED_SCL    7

/* ---------- 3. 全域物件 ---------- */
OneWire oneWire(PIN_1WIRE);
DallasTemperature probes(&oneWire);
DHT dht(PIN_DHT, DHT22);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
Preferences prefs;
String tgQueue[8];            // 離線事件佇列(最多 8 條,先進先出)
uint8_t tgHead = 0, tgCount = 0;
bool relayA = false, relayB = false, hardCut = false;
uint32_t lastSwitchA = 0, lastSwitchB = 0;
uint8_t probeCount = 0;
bool timeSynced = false;
uint32_t lastSample = 0, lastReportDay = 0;
float dayMax = -99, dayMin = 99, daySum = 0; uint16_t dayN = 0, lampMinA = 0, lampMinB = 0;
uint32_t lampOnSinceA = 0, lampOnSinceB = 0;
bool manualOverride = false; uint32_t manualUntil = 0;

/* ---------- 4. 通用工具 ---------- */
/* 用途:事件入佇列並嘗試立即送達,離線則保留重試
 * 參數:msg 訊息;回傳:無 */
void notify(String msg) {
  if (tgCount < 8) tgQueue[(tgHead + tgCount++) % 8] = msg;
  Serial.println("[NOTIFY] " + msg);
}

/* 用途:有網路時清空事件佇列
 * 回傳:成功送達數 */
int flushQueue() {
  if (WiFi.status() != WL_CONNECTED || tgCount == 0) return 0;
  int sent = 0;
  while (tgCount) {
    HTTPClient http;
    http.begin(String("https://api.telegram.org/bot") + TG_TOKEN + "/sendMessage");
    http.addHeader("Content-Type", "application/json");
    StaticJsonDocument<512> doc;
    doc["chat_id"] = TG_CHAT;
    doc["text"] = tgQueue[tgHead];
    char body[512]; size_t n = serializeJson(doc, body);
    if (http.POST((uint8_t*)body, n) == 200) { tgHead = (tgHead + 1) % 8; tgCount--; sent++; }
    else break;                       // 網路半死,下輪再試
    http.end();
  }
  return sent;
}

/* 用途:讀取第 i 支探針;探針不存在或斷線回傳 NAN
 * 參數:i=0 曬背板、i=1 冷區;回傳:°C 或 NAN */
float probeTemp(uint8_t i) {
  if (i >= probeCount) return NAN;
  probes.setResolution(12);           // 12-bit = 0.0625°C 解析度,750ms 轉換
  float t = probes.getTempCByIndex(i);
  return (t == DEVICE_DISCONNECTED_C) ? NAN : t;
}

/* 用途:輸出繼電器(帶最短切換間隔保護,忽略高頻抖動)
 * 參數:ch=A/B;want=true 開燈;回傳:是否實際切換 */
bool setRelay(char ch, bool want) {
  uint32_t now = millis();
  if (ch == 'A') {
    if (want == relayA || now - lastSwitchA < MIN_SWITCH_MS) return false;
    relayA = want; lastSwitchA = now;
    digitalWrite(PIN_RELAY_A, relayA ? HIGH : LOW);
    if (relayA) lampOnSinceA = now;
    else if (lampOnSinceA) lampMinA += (now - lampOnSinceA) / 60000;
    return true;
  } else {
    if (want == relayB || now - lastSwitchB < MIN_SWITCH_MS) return false;
    relayB = want; lastSwitchB = now;
    digitalWrite(PIN_RELAY_B, relayB ? HIGH : LOW);
    if (relayB) lampOnSinceB = now;
    else if (lampOnSinceB) lampMinB += (now - lampOnSinceB) / 60000;
    return true;
  }
}

/* ---------- 5. 時間與排程 ---------- */
void syncTime() {
  configTime(UTC_OFFSET_SEC, 0, "pool.ntp.org", "time.google.com");
  struct tm t;
  timeSynced = getLocalTime(&t, 1200);
  if (timeSynced) Serial.printf("NTP 同步完成 %02d:%02d\n", t.tm_hour, t.tm_min);
}

/* 用途:目前是否處於照明時段(未同步時間時保守視為白天,
 *       因「夜間漏熱」傷害小於「白天斷燈 8 小時」)
 * 回傳:true=白天 */
bool isDaytime() {
  if (!timeSynced) return true;
  struct tm t; getLocalTime(&t, 500);
  int m = t.tm_hour * 60 + t.tm_min;
  return m >= (LIGHT_ON_H * 60 + LIGHT_ON_M) && m < (LIGHT_OFF_H * 60 + LIGHT_OFF_M);
}

/* ---------- 6. 控制核心 ---------- */
/* 用途:依模式執行控溫;含硬截斷與感測器故障保護
 * 參數:tBask, tCool, 有效與否;回傳:無 */
void control(float tBask, float tCool) {
  bool day = isDaytime();
  bool baskOk = !isnan(tBask), coolOk = !isnan(tCool);

  /* 硬截斷:任一探頭過熱全斷;降回 ABS_RECOVER 以下才解除(遲滯防振盪)*/
  if (!hardCut && ((baskOk && tBask > ABS_MAX) || (coolOk && tCool > ABS_MAX))) {
    hardCut = true;
    setRelay('A', false); setRelay('B', false);
    notify("🔥 過熱硬截斷!曬背 " + String(tBask, 1) + "°C 冷區 " + String(tCool, 1) + "°C,兩路全斷,請檢查燈具!");
  } else if (hardCut && tBask < ABS_RECOVER && tCool < ABS_RECOVER) {
    hardCut = false;
    notify("✅ 溫度回落,恢復自動控制");
  }
  if (hardCut) return;

  if (manualOverride) return;                 // 手動模式只保硬截斷

  /* 通道 A:白天依曬背板表面溫;探頭斷線=該通道斷燈(故障安全) */
  if (day && baskOk) setRelay('A', tBask < BASK_ON ? true : (tBask > BASK_OFF ? false : relayA));
  else setRelay('A', false);

  /* 通道 B:夜間依冷區溫度;白天陶熱器關閉(白天靠曬燈就夠) */
  if (!day && coolOk) setRelay('B', tCool < NIGHT_ON ? true : (tCool > NIGHT_OFF ? false : relayB));
  else setRelay('B', false);
}

/* ---------- 7. 顯示與日報 ---------- */
void drawOled(float tBask, float tCool, float tAir, float hum) {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setCursor(0, 0);
  char buf[40];
  struct tm t; bool got = getLocalTime(&t, 0);
  snprintf(buf, sizeof(buf), "%s %s", got ? (t.tm_hour < 19 ? "☀ 白天" : "🌙 夜間") : "⏳未同步",
           hardCut ? "硬截斷!" : (manualOverride ? "手動" : "自動"));
  oled.println(buf);
  snprintf(buf, sizeof(buf), "曬背%5.1f 冷%5.1f", isnan(tBask) ? -99 : tBask, isnan(tCool) ? -99 : tCool);
  oled.println(buf);
  snprintf(buf, sizeof(buf), "燈 A%d B%d 濕%2.0f%%", lampMinA, lampMinB, hum);
  oled.println(buf);
  snprintf(buf, sizeof(buf), "A[%s] B[%s] WiFi%s", relayA ? "ON " : "off", relayB ? "ON " : "off",
           WiFi.status() == WL_CONNECTED ? "✓" : "✗");
  oled.println(buf);
  oled.display();
}

/* 用途:21:00 推送當日統計並重置累加器 */
void dailyReport() {
  struct tm t; if (!getLocalTime(&t, 100)) return;
  uint32_t today = 10000UL * (t.tm_year + 1900) + 100UL * (t.tm_mon + 1) + t.tm_mday;
  if (t.tm_hour == 21 && lastReportDay != today) {
    lastReportDay = today;
    notify("🦎 阿旺日報 " + String(t.tm_mon + 1) + "/" + String(t.tm_mday) +
           String("\n曬背最高 ") + String(dayMax, 1) + "°C 最低 " + String(dayMin, 1) +
           "°C 均值 " + String(daySum / max(dayN, 1), 1) + "°C" +
           "\n曬燈 " + String(lampMinA) + " 分 陶熱 " + String(lampMinB) + " 分");
    dayMax = -99; dayMin = 99; daySum = 0; dayN = 0; lampMinA = 0; lampMinB = 0;
  }
}

/* ---------- 8. setup ---------- */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_RELAY_A, OUTPUT); pinMode(PIN_RELAY_B, OUTPUT);
  digitalWrite(PIN_RELAY_A, LOW); digitalWrite(PIN_RELAY_B, LOW);  // 上電先全斷
  pinMode(PIN_BTN, INPUT_PULLUP);

  prefs.begin("thermo");
  Wire.begin(OLED_SDA, OLED_SCL);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  oled.clearDisplay();

  probes.begin();
  probeCount = probes.getDeviceCount();
  dht.begin();
  notify("⚡ 恆溫器上電(探針數:" + String(probeCount) + ")");
  if (probeCount < 2) notify("⚠️ 偵測到 " + String(probeCount) + " 支探針,請檢查 1-Wire 接線");

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  syncTime();
  dailyReportBoot();                    // 見下方說明:只發上電通知,其餘交佇列
}

/* 用途:上電補發斷電通知用的輔助(先收佇列再同步 flush) */
void dailyReportBoot() { /* no-op:notify 已入佇列,loop 首輪 flush */ }

/* ---------- 9. loop ---------- */
void loop() {
  uint32_t now = millis();

  if (WiFi.status() != WL_CONNECTED && now % 30000 < 100) WiFi.reconnect();
  if (!timeSynced && now % 600000 < 100) syncTime();      // 每 10 分鐘重試 NTP
  flushQueue();

  /* 手動按鈕:長壓 2 秒進入/離開 30 分鐘強制開燈(換燈測試用) */
  static uint32_t btnDown = 0;
  if (digitalRead(PIN_BTN) == LOW) { if (btnDown == 0) btnDown = now; }
  else if (btnDown && now - btnDown > 2000) {
    manualOverride = !manualOverride; manualUntil = now + 30UL * 60000;
    notify(manualOverride ? "🔧 手動模式:兩路強制開燈 30 分鐘" : "↩️ 恢復自動模式");
    btnDown = 0;
  }
  if (manualOverride && now > manualUntil) manualOverride = false;
  if (manualOverride) { digitalWrite(PIN_RELAY_A, HIGH); digitalWrite(PIN_RELAY_B, HIGH); }

  /* 每 5 秒取樣一輪(12-bit 轉換 750ms、DHT 採樣率 0.5Hz,皆足夠) */
  if (now - lastSample >= 5000) {
    lastSample = now;
    probes.requestTemperatures();               // 呼叫後下次 loop 讀值最穩
    float tBask = probeTemp(0), tCool = probeTemp(1);
    float tAir = dht.readTemperature(), hum = dht.readHumidity();

    if (isnan(tBask) || isnan(tCool)) notify("⚠️ 探頭讀值異常(斷線或幹擾),該迴路已斷燈");
    if (tBask > -50) { dayMax = max(dayMax, tBask); dayMin = min(dayMin, tBask);
                       daySum += tBask; dayN++; }
    control(tBask, tCool);
    dailyReport();
    drawOled(tBask, tCool, tAir, hum);
  }
  delay(20);
}
設計決策:① 兩支探針共用一條 1-Wire,省 GPIO 也省線材——DallasTemperature 自動按 ROM 序號排序,上電先記好 #1/#2 誰是誰再固定安裝位置。② 硬截斷用 42°C 而非 45°C,留 KSD9700 動作誤差 3°C 的餘裕。③ 未同步時間時保守判白天:漏夜熱一晚(冷區掉到 18°C)的傷害,遠小於大白天斷燈 8 小時。

🏗️ 組裝步驟

完整圖文版:7 步驟 IKEA 組裝說明書 →(含強電接線圖、探針校準、KSD9700 測試)
  1. 列印 4 種 PETG 零件;主控盒先試裝(USB-C 孔、OLED 視窗銑通)
  2. 麵包板接 USB 供電+探針+繼電器模組(燈全不接),刷韌體驗證三探頭數值與 OLED
  3. 校準:探針與水銀溫度計同插一杯自來水,>1°C 偏差者記錄補正值寫入程式
  4. 強電:插頭→保險絲座→KSD9700→燈座依序接進封閉端子盒,電表量通斷後才試燈
  5. 探針夾固定於曬背板與冷區、KSD9700 鎖上燈殼散熱片,走線夾整理線材
  6. 上電讓它跑一整天,比對手機溫度計記錄;21:00 收到日報即上線

🔧 問題排除

症狀根因解法
OLED 秀 −99.0(探頭失聯)1-Wire 斷線或上拉電阻鬆脫量 DQ-3V3 靜態應 ≈3.3V;重鎖 4.7k;探針端並 0.1µF
曬背板明明 36°C 燈卻狂開關探針懸空只測到熱氣流,振盪跨越設定點探針貼緊板面綁牢,拉大遲滯至 BASK_ON+0.5
KSD9700 沒動作燈卻狂燒只貼空氣沒鎖散熱片,感測不到熱傳導M3 鎖緊金屬面;測試:熱風槍近距離應 30 秒內斷開
寒流夜通道 B 整天不關仍升不上 21.5°C陶熱器瓦數不足或箱體通風過大換 100W(確認燈座耐溫);箱頂加防風板;勿用燈泡換陶熱器硬扛
每天固定時間斷連、上電通知重發Wi-Fi 路由每日重啟,佇列於恢復後補發正常行為;若通知重複發送再檢查 flushQueue 重試邏輯
OLED 偶爾整屏雪花後當機繼電器線圈雜訊經 5V 耦合5V 進板前加 220µF+100nF;USB 變壓器換品牌
半夜告警「過熱硬截斷」但摸燈不熱探針被鬃獅蜥爬到身下壓住,讀到體溫+曬燈雙重加熱探針改綁燈罩內側背風面,或加列印護橋跨過曬背板上方

🚀 進階應用

📦 檔案清單

c3-terrarium-thermostat/
├─ c3-terrarium-thermostat.ino   ← 本頁完整韌體
├─ README.md                     ← 接線表+踩坑紀錄
└─ prints/
   ├─ thermo_case_v2.petg.stl       # 主控牆掛盒(PETG)
   ├─ probe_clip_side.petg.stl      # 探針應變消除夾 ×2
   ├─ ksd_mount_m3.petg.stl         # KSD9700 燈座固定片 ×2
   └─ cable_clip_tank.stl           # 箱緣走線夾 ×4