💻 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 小時。