📋 專案概述
遠端工作第四年,我的桌面行為模式沒變過:10:30 泡好一杯凍頂,開會、回信、趕報告,11:15 想起那杯茶——摸下去是涼的。「茶涼了再微波」聽起來沒損失,但一年下來倒掉的茶、重燒的水、還有對著馬克杯皺眉的次數,早就超過一杯茶本身價值的幾百倍。問題從來不是茶,是「剛好入口的溫度」這件事沒人幫你守。
市售恆溫杯墊我們拆過三款,各有各的死法:
- 無溫控純發熱款(USB,約 350 元):5V 供電功率天花板 10W,夏天能溫、冬天窗邊風一吹就涼;恆功率 55°C「恆」的是發熱片不是你的茶——杯子從滿喝到見底,溫度一路往下走。
- 數顯恆溫款(約 900 元):有閉環了,但溫度感測在墊子上不在杯底附近,厚杯底隔熱玻璃杯形同失準;而且它沒有傾倒斷電。
- 「智能」App 款(1,500 元+):把開關做進 App,溫控曲線還是上述水準——賣的是藍牙晶片不是 PID。
真正讓人半夜睡不著的場景只有一個:杯子倒了,發熱墊壓在木桌上,繼續燒。所以本專案的規格順序是反的——先保險、再溫控、最後才談智慧:
- 三層獨立保險:韌體過溫鎖(>70°C 斷輸出鎖機)→ 2A 保險絲 → 76°C 溫度保險絲直接串在發熱迴路——就算韌體整個燒糊,電流通路物理斷開
- 傾倒 2 秒斷電鎖死:滾珠開關偵測翻倒,先斷輸出再鎖機,必須人工按鍵解鎖
- NTC 埋鋁板閉環 PID:抗 windup、derivative on measurement,目標 45 / 55 / 65°C 三檔(溫奶/綠茶/咖啡)按鍵切換
- 乾燒偵測:全輸出 90 秒鋁板溫升不到 0.5°C = 沒放杯子(或杯子太厚),自動待機,放上杯子自己續熱
- NAS 曲線:每 30 秒 heartbeat 記錄溫度/功率,失聯 3 次自動重連;月底看你一天認真是不是只有上午兩杯
成本:整組 NT$450 上下(含鋁板與雙保險絲)。市售無溫控款 350 元買一個「永遠 55°C 的發熱片」;這台多花 100 元,買到閉環、傾倒斷電、乾燒保護和整條溫度曲線。茶涼一盞不值錢,發熱墊壓在木桌上的畫面才值錢。
為什麼選 ESP32-C6?
| 需求 | C6 的優勢 |
|---|---|
| 常時 Wi-Fi 傳 NAS | 與 C3 同價就有 Wi-Fi 6;小封包 heartbeat 搶通道更積極,家用 Wi-Fi 6 AP 下明顯比較不掉線 |
| 雙軌供電 | 5V 直接進 5V 腳走板載 LDO,12V→5V 一顆 MP1584 收工;PWM 與 ADC 通道都多,發熱與邏輯互不搶腳 |
| 體積 | SuperMini 25×25mm,藏進杯墊外殼電路艙剛好 |
| 成本 | ~90 元,比 BLE 方案(还要加網關)便宜,比 S3 便宜 120 元——本專案不需要 USB OTG,S3 的優勢用不上 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C6 SuperMini | 1 | ~90 元 | 5V 進 5V 腳供電 |
| 發熱 | 矽膠發熱墊 12V 18W(Ø10cm) | 1 | ~90 元 | 12V 是重點:5V 款功率不夠冬天用 |
| 開關管 | IRLZ44N+小散熱片 | 1 | ~20 元 | 低邊切換;gate 10k 下拉必裝(見下方降額說明) |
| 溫度 | NTC 100K B3950 防水探頭 | 1 | ~35 元 | 埋鋁板側孔+導熱膏貼實 |
| 均溫 | 鋁板 Ø12cm × 1mm | 1 | ~40 元 | 鑽 NTC 孔+銑/磨杯底淺溝防滑 |
| 顯示 | SSD1306 0.96" I2C | 1 | ~90 元 | 顯示目標/實際/功率 |
| 傾倒 | SW-520D 滾珠開關 | 1 | ~15 元 | 豎直安裝,平裝無效 |
| 降壓 | MP1584 模組 12V→5V | 1 | ~20 元 | 接上負載後再微調到 5.0V |
| 保險 | 2A 保險絲座+76°C 溫度保險絲 | 各 1 | ~35 元 | 串發熱迴路,不可省 |
| 按鍵 | 6×6mm 微動 | 1 | ~5 元 | 切檔/開關機/解鎖 |
| 蜂鳴器 | 3.3V 有源 | 1 | ~15 元 | 警示與操作回饋 |
| 電源 | 12V 2A 適配器(帶地最好) | 1 | ~120 元 | 舊數據機變壓器即可 |
| 列印 | PETG 約 80g | — | ~25 元 | ⚠️ 禁 PLA,見列印章節 |
| 合計 | ~515 元 | 適配器用舊物則 ~395 元 |
🔌 接線圖
詳細接線
| ESP32-C6 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO4(ADC1) | NTC 分壓中點 | 100K 上拉至 3V3、NTC 下臂到 GND;ADC 讀分壓 |
| GPIO6(LEDC) | IRLZ44N gate | 100Ω 串阻防振鈴+10k 下拉:開機與韌體掛掉時發熱墊必關機(靠下拉,不靠韌體) |
| GPIO20 / GPIO21 | OLED SDA / SCL | I2C 0x3C |
| GPIO1 | 按鍵 → GND | 內部上拉 |
| GPIO3 | 傾倒開關 → GND | 內部上拉;正常直立=高、翻倒瞬間翻低 |
| GPIO0 | 蜂鳴器 + | 高準位發聲 |
| 5V / GND | MP1584 輸出 | 帶OLED 總流 < 200mA,MP1584 線調到 5.0V 再進板 |
發熱迴路(12V 側,與邏輯電路分離)
12V(+) ──[2A 保險絲]──[76°C 溫度保險絲]── 發熱墊(+)
發熱墊(−) ── IRLZ44N Drain
IRLZ44N Source ── GND(與 C6 共地)
IRLZ44N Gate ── 100Ω ── GPIO6
└─ 10kΩ 下拉至 GND(勿省!)
NTC 探頭埋鋁板側孔(導熱膏);鋁板平壓發熱墊上
MOSFET 驅動小聲:3.3V 夠嗎?
| Vgs | IRLZ44N Rds(on) | 1.5A 時發熱 | 結論 |
|---|---|---|---|
| 5V( datasheet 保證) | ≤ 22mΩ | ~0.05W | 冰涼 |
| 3.3V(無保證值,實測約) | 0.2~0.5Ω | 0.5~1.1W | 散熱片必裝,可接受 |
| Vgs(th) 2V(別看錯) | 只是「剛開始導通」的門檻,不是飽和值 | 別拿來當驅動依據 | |
12V×18W 只有 1.5A,3.3V 驅動的 IRLZ44N 加散熱片後在安全负荷內。想要更乾淨,換 AO3400A(3.3V 特性曲線明確、SOT-23 好焊)或 IRLML6302(Vgs 2.5V 有保證曲線、Vds 20V ≥ 12V 工作電壓)即可,腳位線路不用改。
🔥 本專案接的是 12V DC、18W。全程不用市電 110V——請不要把本專案改成 110V 加熱棒版本;市電版的絕緣、接地、漏電保護是另一個工程量,不是一條線的事。
🖨️ 3D 列印零件(⚠️ 禁 PLA)
| 零件 | 數量 | 材質 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 外殼下座(電路艙+保險絲艙) | 1 | PETG / ASA | 淺灰 | ~3 hr |
| 鋁板定位圈(壓住 Ø12 鋁板) | 1 | PETG | 淺灰 | ~35 min |
| OLED 遮光罩立柱 | 1 | PETG | 黑 | ~20 min |
| 按鍵帽(耐熱造型矮帽) | 1 | PETG | 木紋色 | ~10 min |
| 輪廓矽膠腳墊座 | 1 | PETG | 任何 | ~15 min |
🥲 血淚教學:第一代用 PLA 印,連續使用第三天外殼貼著 65°C 鋁板那側開始蠕變、定位圈塌掉、鋁板翹一邊乾燒報警狂叫。PLA 玻璃轉移溫度約 60°C,本專案的常態環境就是 45~70°C。PETG 耐 80°C、ASA 耐 95°C 還抗 UV(窗邊用),多花 5 分鐘換料,少走三天冤枉路。
設計要點
- 熱區/電路區分艙:MOSFET 與 MP1584 放「下風口」側,OLED 與 C6 放另一側,隔艙板開對流孔 4×3mm×6
- 保險絲可換艙:保險絲座鎖外壁,螺蓋打开就換,不用拆機
- 鋁板壓邊 1.5mm:定位圈壓住鋁板不滑移,但留熱膨脹縫隙
- 底部 4 顆矽膠腳墊:木桌面隔熱,也是傾倒後的第一道缓冲
- 走線孔與發熱墊本體 10mm 以上:一切低壓線避開傳熱路徑
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
* ESP32-C6 恆溫智慧隔熱杯墊 | PID Mug Warmer
* ------------------------------------------------------------
* 功能:NTC 閉環 PID 恆溫(45/55/65°C 三檔)+ 傾倒斷電鎖機
* + 乾燒偵測(無杯待機)+ NAS heartbeat 溫度曲線
* 硬體:ESP32-C6 SuperMini + 12V 18W 矽膠發熱墊(IRLZ44N 低邊)
* + NTC 100K B3950 + SSD1306 + SW-520D 傾倒開關
* 供電:12V 2A →(2A+76°C 雙保險)→ 發熱墊;12V→MP1584→5V→板
* 相依:Arduino core for ESP32 ≥ 3.0(WiFi / HTTPClient / LEDC)
* Adafruit SSD1306 + Adafruit GFX
* ⚠ 安全前提:韌體只是第三層保險。76°C 溫度保險絲與 2A 保險絲
* 必須物理串聯在發熱迴路——保險絲被跳線=本專案禁止開機。
* ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <math.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
/* ------------------------------------------------------------
* 1. 使用者設定
* ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* NAS_BASE = "http://192.168.1.10:5000"; // NAS 接收服務
/* ------------------------------------------------------------
* 2. 腳位
* ------------------------------------------------------------ */
const int PIN_NTC = 4; // ADC1:NTC 分壓中點
const int PIN_GATE = 6; // LEDC:MOSFET gate(10k 下拉必裝)
const int PIN_TILT = 3; // 傾倒開關(對 GND,內部上拉)
const int PIN_BUTTON = 1; // 按鍵(對 GND,內部上拉)
const int PIN_BUZZER = 0; // 有源蜂鳴器
const uint32_t PWM_FREQ = 1000; // 1kHz:MOSFET 開關損耗低、固態元件無聲
const uint8_t PWM_RES = 10; // 10-bit → 0~1023
const float PWM_MAX = 0.85f; // 輸出上限 85%:留給 PID 過衝餘裕,永不 100% 硬怼
/* ------------------------------------------------------------
* 3. 溫控參數
* ------------------------------------------------------------ */
const float TARGETS[3] = {45.0f, 55.0f, 65.0f}; // 奶 / 茶 / 咖啡
const char* TGT_NAME[3] = {"MILK", "TEA", "COFFEE"};
uint8_t tgtIdx = 1;
const float HYST_ON = 1.5f; // 低於目標 1.5°C 才允許升功率(遲滯下緣)
const float HYST_OFF = 0.8f; // 高於目標 0.8°C 輸出直接歸零(遲滯上緣)
const float OVERTEMP_C = 70.0f; // 韌體過溫鎖(物理保險是 76°C,留 6°C 給物理層)
const float KP = 60.0f, KI = 0.6f, KD = 8.0f; // 對 18W/鋁板 的保守值:宁慢勿衝
const float ITERM_MAX = 25.0f; // integral clamp:抗 windup 的關鍵
const unsigned long CTRL_DT_MS = 500; // PID 週期 0.5s(熱常數是分鐘級,快無意義)
/* ------------------------------------------------------------
* 4. 狀態機
* ------------------------------------------------------------ */
enum State { ST_OFF, ST_HEAT, ST_NOCUP, ST_LOCKOUT };
State state = ST_OFF; // 開機預設待機:跳電重啟≠半夜自己加熱
unsigned long lockTiltSince = 0; // 傾倒持續計時(防震動誤觸)
unsigned long heatFullSince = 0; // 乾燒偵測:輸出>60% 起算時間
unsigned long heatSince = 0; // 慢啟動計時(enterState 進入 HEAT 時重設)
float cupBaseTemp = 0; // 乾燒起點基準溫
bool emaValid = false;
float tempEma = 25.0f; // EMA 後溫度(PID 與顯示都用它)
float integ = 0, lastMeas = 25;
unsigned long lastCtlMs = 0, lastBeatMs = 0, lastUiMs = 0;
uint8_t beatFail = 0;
String lastErr = "";
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
/* ------------------------------------------------------------
* beep —— 蜂鳴器音型
* 參數:on/off=ms 長短,times=連響(阻塞≤900ms,警示場景可接受)
* ------------------------------------------------------------ */
void beep(uint16_t onMs, uint16_t offMs, uint8_t times) {
for (uint8_t i = 0; i < times; i++) {
digitalWrite(PIN_BUZZER, HIGH); delay(onMs);
digitalWrite(PIN_BUZZER, LOW); if (i < times - 1) delay(offMs);
}
}
/* ------------------------------------------------------------
* setPwm —— 輸出封裝(所有切熱必經此門)
* 參數:duty 0.0~1.0;內部再做上限與狀態封鎖
* 為什麼封裝:任何模組想「直接 ledcWrite」都可能繞過安全上限
* ------------------------------------------------------------ */
void setPwm(float duty) {
if (state == ST_OFF || state == ST_LOCKOUT) duty = 0; // NOCUP 放行:恢復探測會走這路給電(見 loop)
duty = constrain(duty, 0.0f, PWM_MAX);
ledcWrite(0, (uint32_t)(duty * ((1UL << PWM_RES) - 1)));
}
/* ------------------------------------------------------------
* ntcTemp —— 讀取 NTC 溫度(攝氏)
* 做法:13 樣本去極值取中位數 → 分壓換電阻 → β 公式 → EMA
* 回傳:平滑後溫度;讀到 <-10 或 >125 視為探頭故障(回傳 NaN)
* 為什麼:ADC 雜訊經 β 公式會放大,中位數+EMA 雙級濾波不可省
* ------------------------------------------------------------ */
float ntcTemp() {
const int N = 13;
int s[N];
for (int i = 0; i < N; i++) { s[i] = analogRead(PIN_NTC); delayMicroseconds(200); }
for (int i = 1; i < N; i++) { // 插入排序(13 個元素)
int k = s[i], j = i - 1;
while (j >= 0 && s[j] > k) { s[j + 1] = s[j]; j--; }
s[j + 1] = k;
}
float v = s[N / 2] * (3.3f / 4095.0f); // 中位數 → 電壓
if (v < 0.02f || v > 3.28f) return NAN; // 探頭斷路/短路
float r = 100000.0f * (3.3f / v - 1.0f); // 上臂 100K,NTC 下臂
float tK = 1.0f / ((log(r / 100000.0f) / 3950.0f) + 1.0f / 298.15f);
float tC = tK - 273.15f;
if (tC < -10.0f || tC > 125.0f) return NAN;
tempEma = emaValid ? (tempEma * 0.85f + tC * 0.15f) : tC; // α=0.15:≈5 拍收敛
emaValid = true;
return tempEma;
}
/* ------------------------------------------------------------
* pid —— 位置式 PID(derivative on measurement)
* 參數:target 目標溫、meas 實際溫、dt 秒
* 回傳:0~1 占空比
* 為什麼 D 項用測量值:目標檔位一切換,error 微分是脈衝,
* 用誤差微分會給鋁板當頭一棒 D 踢
* ------------------------------------------------------------ */
float pid(float target, float meas, float dt) {
float err = target - meas;
integ += err * dt;
integ = constrain(integ, -ITERM_MAX, ITERM_MAX); // 抗 windup
float dOut = -KD * (meas - lastMeas) / dt; // 負號=減緩上升速率
lastMeas = meas;
return (KP * err + KI * integ + dOut) / 100.0f; // 歸一化
}
/* ------------------------------------------------------------
* enterState —— 狀態切換集中處理(含蜂鳴與 PID 重置)
* ------------------------------------------------------------ */
void enterState(State s) {
state = s; integ = 0; lastMeas = tempEma;
ledcWrite(0, 0);
switch (s) {
case ST_LOCKOUT: beep(300, 100, 3); break; // 長嗶三聲:出事
case ST_NOCUP: beep(120, 150, 3); break; // 沒杯子:中嗶
case ST_HEAT: beep(100, 0, 1); heatSince = millis(); break; // 開始加熱:短嗶+慢啟動計時
default: break;
}
}
/* ------------------------------------------------------------
* safetyTick —— 100ms 安全巡檢:傾倒 2 秒 + 過溫 + 感測失聯
* 獨立於 PID 週期:安全邏輯不跟控制迴圈排隊
* ------------------------------------------------------------ */
void safetyTick() {
float t = tempEma;
if (isnan(t)) { if (state != ST_LOCKOUT) { lastErr = "NTC FAIL"; enterState(ST_LOCKOUT); } return; }
if (t > OVERTEMP_C && state != ST_LOCKOUT) { lastErr = "OVERTEMP"; enterState(ST_LOCKOUT); return; }
bool tiltBad = (digitalRead(PIN_TILT) == LOW); // 正常直立=HIGH
if (tiltBad) {
if (lockTiltSince == 0) lockTiltSince = millis();
if (millis() - lockTiltSince > 2000 && state != ST_LOCKOUT) {
lastErr = "TIPPED"; enterState(ST_LOCKOUT); // 先斷輸出再鎖機
}
} else lockTiltSince = 0;
}
/* ------------------------------------------------------------
* cupDetect —— 乾燒/無杯偵測(僅 HEAT 分支)
* 邏輯:輸出>60% 超過 90 秒,鋁板卻升不到 0.5°C → 熱根本沒
* 進到系統(沒杯或探頭鬆脫)→ 進 NOCUP 待機省電
* 恢復探測放在 loop 的 NOCUP 區塊:定時試熱,杯子放回即續熱
* ------------------------------------------------------------ */
void cupDetect(float pwm) {
if (state == ST_HEAT && pwm > 0.6f) {
if (heatFullSince == 0) { heatFullSince = millis(); cupBaseTemp = tempEma; }
else if (millis() - heatFullSince > 90000UL && tempEma - cupBaseTemp < 0.5f) {
enterState(ST_NOCUP);
}
} else heatFullSince = 0;
}
/* ------------------------------------------------------------
* pollButton —— 按鍵手勢(50ms 去抖)
* 短按:待機→加熱/加熱中切檔/LOCK! 人工復歸/NOCUP 放杯重試
* 長按 2 秒:加熱↔待機切換(按住即觸發,放開不會再誤補短按)
* 為什麼短按在「放開」才觸發:長按轉短按的手勢不會先誤切檔
* ------------------------------------------------------------ */
void pollButton() {
static bool rawPrev = HIGH, longFired = false;
static unsigned long changeMs = 0, downMs = 0;
bool raw = digitalRead(PIN_BUTTON);
unsigned long now = millis();
if (raw != rawPrev) {
if (now - changeMs < 50) return; // 彈跳視窗內的雜訊直接丟
rawPrev = raw; changeMs = now;
if (raw == LOW) { downMs = now; longFired = false; }
else if (!longFired && now - downMs > 60) { // 短按放開
if (state == ST_LOCKOUT) { lastErr = ""; enterState(ST_OFF); }
else if (state == ST_HEAT) { tgtIdx = (tgtIdx + 1) % 3; integ = 0; beep(60, 0, 1); }
else if (state == ST_OFF) { lastMeas = tempEma; enterState(ST_HEAT); }
else if (state == ST_NOCUP) enterState(ST_HEAT); // 使用者剛把杯子放回去
}
}
if (raw == LOW && !longFired && now - downMs > 2000) { // 長按觸發
longFired = true;
if (state == ST_HEAT) enterState(ST_OFF);
else if (state == ST_OFF) { lastMeas = tempEma; enterState(ST_HEAT); }
}
}
/* ------------------------------------------------------------
* heartbeat —— NAS 上報(30s)+ 失聯 3 次 Wi-Fi 重關
* ------------------------------------------------------------ */
void heartbeat(float pwm) {
if (millis() - lastBeatMs < 30000UL) return;
lastBeatMs = millis();
if (WiFi.status() != WL_CONNECTED) { beatFail++; if (beatFail >= 3) { WiFi.disconnect(); WiFi.begin(); beatFail = 0; } return; }
HTTPClient http;
String url = String(NAS_BASE) + "/warmer_beat?temp=" + String(tempEma, 1)
+ "&pwm=" + String(pwm, 2) + "&mode=" + String((int)state)
+ "&tgt=" + String(TARGETS[tgtIdx], 0);
http.begin(url);
int code = http.GET();
http.end(); // 釋放連線(常時駐留裝置務必關)
if (code != 200 && ++beatFail >= 3) { WiFi.disconnect(); WiFi.begin(); beatFail = 0; }
}
/* ------------------------------------------------------------
* alarmOnce —— 過溫/傾倒事件補發(各只發一次,解鎖後重置)
* ------------------------------------------------------------ */
void alarmOnce() {
static String lastSent = "";
if (state == ST_LOCKOUT && lastErr != "" && lastErr != lastSent && WiFi.status() == WL_CONNECTED) {
HTTPClient http; http.begin(String(NAS_BASE) + "/warmer_alarm?reason=" + lastErr);
if (http.GET() == 200) lastSent = lastErr;
http.end();
}
}
/* ------------------------------------------------------------
* drawUi —— OLED:大字溫度+目標+功率條+狀態
* ------------------------------------------------------------ */
void drawUi(float pwm) {
if (millis() - lastUiMs < 250) return; // 4Hz:I2C 全幀重繪限流
lastUiMs = millis();
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(2); oled.setCursor(0, 0);
if (isnan(tempEma)) oled.print("ERR"); else oled.printf("%d", (int)tempEma);
oled.setTextSize(1);
oled.setCursor(92, 0);
oled.printf("%dC", (int)TARGETS[tgtIdx]); // 右上角顯示目標溫
oled.setCursor(0, 28);
const char* st = (state == ST_OFF) ? "STANDBY" : (state == ST_HEAT) ? TGT_NAME[tgtIdx]
: (state == ST_NOCUP) ? "NO CUP" : "LOCK!";
oled.printf("%-6s P:%3d%% \n", st, (int)(pwm * 100));
oled.drawRect(0, 42, 126, 8, SSD1306_WHITE);
oled.fillRect(2, 44, (int)(pwm * 122), 4, SSD1306_WHITE); // 功率條
if (lastErr != "") { oled.setCursor(0, 54); oled.printf("%s", lastErr.c_str()); }
oled.printf("%s", WiFi.status() == WL_CONNECTED ? "NET" : "----");
oled.display();
}
/* ------------------------------------------------------------
* setup —— 開機:腳位 → OLED → PWM → Wi-Fi(失敗不擋加熱)
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200); delay(800);
Serial.println(F("\n=== ESP32-C6 Mug Warmer (safety-first) ==="));
Serial.println(F("⚠ 開機前確認:2A 保險絲與 76°C 溫度保險絲已串入發熱迴路"));
pinMode(PIN_BUZZER, OUTPUT); digitalWrite(PIN_BUZZER, LOW);
pinMode(PIN_BUTTON, INPUT_PULLUP);
pinMode(PIN_TILT, INPUT_PULLUP);
analogSetAttenuation(ADC_11db); // NTC 分壓可近全量程,別用預設 0~1V 檔
Wire.begin(20, 21);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C))
oled.begin(SSD1306_SWITCHCAPVCC, 0x3D);
ledcSetup(0, PWM_FREQ, PWM_RES);
ledcAttachPin(PIN_GATE, 0);
setPwm(0); // 先封鎖再放行:開機永遠全關
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD); // 連線中也能加熱:Wi-Fi 是記錄不是依賴
}
/* ------------------------------------------------------------
* loop —— 500ms 控制週期 + 100ms 安全 + UI/心跳
* 安全優先序:safetyTick 排在 PID 前,本輪已鎖機就不執行 setPwm
* ------------------------------------------------------------ */
void loop() {
float t = ntcTemp();
safetyTick();
static float duty = 0;
if (millis() - lastCtlMs >= CTRL_DT_MS) {
lastCtlMs = millis();
float target = TARGETS[tgtIdx];
if (state == ST_HEAT) {
float cmd = pid(target, isnan(t) ? 999 : t, CTRL_DT_MS / 1000.0f);
// 遲滯門:升溫要低於 target-HYST_ON 才給油;超 target+HYST_OFF 直接斷油
if (t > target + HYST_OFF) cmd = 0;
else if (t > target - HYST_ON && duty > 0) cmd = max(cmd, 0.0f); // 保持帶:只維持不清零
else if (t > target - HYST_ON && duty == 0) cmd = 0; // 停等區:不抖
// 慢啟動:本輪進入加熱的前 30 秒輸出壓在 25%,冷鋁板不熱衝擊(enterState 計時)
if (millis() - heatSince < 30000UL) cmd = min(cmd, 0.25f);
duty = cmd;
} else duty = 0;
setPwm(duty);
cupDetect(duty);
/* NOCUP 恢復探測:每 25 秒一輪——試熱 8 秒、等待 17 秒;
試熱窗內鋁板升超過 0.5°C → 杯子回來了,回 HEAT 續熱 */
static unsigned long nocupCycleMs = 0;
if (state == ST_NOCUP) {
if (nocupCycleMs == 0) { nocupCycleMs = millis(); cupBaseTemp = tempEma; }
unsigned long el = millis() - nocupCycleMs;
if (el < 8000UL) setPwm(0.7f);
else if (el < 25000UL) setPwm(0.0f);
else {
bool back = (tempEma - cupBaseTemp) > 0.5f;
nocupCycleMs = 0;
if (back) enterState(ST_HEAT);
}
} else nocupCycleMs = 0;
}
pollButton();
drawUi(duty);
heartbeat(duty);
alarmOnce();
delay(10); // 讓出 CPU 給 Wi-Fi 堆疊
}
⚠️ 上電順序檢查:先燒韌體 → 量 MOSFET gate 對地電壓=0V → 再接發熱墊。任何一步電壓不是 0,停止接發熱墊,查 10k 下拉。
🖥️ NAS 端(Node.js 20 行)
// warmer-guard.mjs — heartbeat 記錄與過溫推播
import { createServer } from "node:http";
import { appendFile } from "node:fs/promises";
const TG = { token: process.env.TG_TOKEN, chat: process.env.TG_CHAT };
createServer(async (req, res) => {
const u = new URL(req.url, "http://x");
if (u.pathname === "/warmer_beat") {
await appendFile("/warmer/beat.log",
`${Date.now()},${u.searchParams.get("temp")},${u.searchParams.get("pwm")}\n`);
}
if (u.pathname === "/warmer_alarm") {
await fetch(`https://api.telegram.org/bot${TG.token}/sendMessage`, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ chat_id: TG.chat,
text: `☕⚠ 杯墊告警:${u.searchParams.get("reason")}(物理 76°C 保險為最後一道)` }) });
}
res.end("ok");
}).listen(5000);
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 列印零件:PETG/ASA,5 個零件;保險絲艙的螺孔記得預留
- 12V 迴路先焊、先驗:12V → 保險絲座 → 溫度保險絲 → 發熱墊,萬用表量通斷;此時不接 MOSFET,直接點一下發熱墊 5 秒確認會熱後拆開
- MOSFET 三腳:TO-220 字面朝自己、由左至右 G-D-S,散熱片+導熱墊片鎖好;gate 100Ω 串、10k 下拉
- NTC 埋板:鋁板側孔 4mm 塞入探頭頭部+導熱膏,膠槍封孔固定;探頭歪斜=溫控失準
- MP1584 調壓:接上 OLED+C6 後再微調輸出至 5.0V(空載調載會飄)
- 燒錄與安全驗收:按下面五項驗收表逐項打勾(傾倒、乾燒、過溫、保險絲、開機全關)
- 合殼開跑:排線離發熱墊 10mm 以上,束線帶固定,PETG 外殼扣合
安全驗收表(缺一項不准日常用)
| # | 測試 | 預期 |
|---|---|---|
| 1 | 開機不碰按鍵,量 MOSFET gate 對地 | 0V,發熱墊不熱 |
| 2 | 加熱中把整台翻倒 3 秒扶回 | 長嗶鎖機、顯示 LOCK!,按鍵才解鎖 |
| 3 | 空板(不放手沖杯)啟動 55°C 檔 90 秒 | 中嗶三聲進入 NO CUP;手掌壓板面後 20 秒內自動續熱 |
| 4 | 探頭拔離鋁板握在手心加熱 | 讀溫明顯追手心溫度=感測路徑正常;不動=探頭失準先修 |
| 5 | 過溫模擬:探頭貼熱水杯(≤68°C 水!) | 逼近 70°C 前鎖機、NAS 收 Telegram 告警 |
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| MOSFET 燙手 | gate 沒 10k 下拉/沒散熱片/PWM 期間半導通 | 補下拉與散熱片;換 AO3400(3.3V 曲線明確) |
| 溫度漂、恆不準 | NTC 未貼鋁板(缺導熱膏)或探頭受杯緣遮蓋不均 | 重新埋孔貼實;鋁板杯底溝槽居中 |
| 插 12V OLED 重啟 | MP1584 空載調壓飄高,板載 LDO 進電 >5.5V 保護 | 帶載重調 5.0V;仍飄=MP1584 輸出電容加 100µF |
| 翻倒沒斷電 | SW-520 平裝,滾珠不靠重力 | 改豎直安裝;用膠代固定時保持本體中軸垂直 |
| 半夜自己加熱 | 不應發生——韌體開機預設 STANDBY | 若你改過程式加回「記憶上次的模式」,改回來;跳電重啟必須人工啟動 |
| Wi-Fi 連線成功但 beat 全失 | NAS 防火牆擋區域外 POST/PORT 不對 | 瀏覽器開 beat URL 應回 ok;確認 5000 埠對內網開放 |
| 顯示溫度 85°C 亂跳 | ADC 腳接了 PWM 的雜訊地線迴路 | NTC 分壓地線單獨回 C6 GND,別串在 MOSFET 地線上 |
🎯 進階延伸
- 有杯偵測硬體化:FSR 壓力感測膜+HX711,重量>50g 才准加熱,乾燒偵測從「軟體猜」變「直接量」
- NAS 儀表板:beat.log 匯入 Grafana,飲用時段熱力圖+每日杯數;月底報告「專注時段分佈」順便有
- 雙區版:LEDC 再開一通道驅第二顆 MOSFET,C6 剩餘 ADC 讀第二顆 NTC,一邊咖啡一邊奶
- PID Web 調參:加 httpd 頁面即時改 KP/KI/KD,配 NAS 曲線現場調(程式碼已留結構)
📦 檔案清單
c6-mug-warmer/
├── src/
│ └── mug-warmer.ino # ESP32 程式碼(本頁完整程式)
│ └── warmer-guard.mjs # NAS 記錄+推播(本頁完整程式)
├── stl/
│ ├── base-shell.stl # 外殼下座(PETG)
│ ├── plate-ring.stl # 鋁板定位圈
│ ├── oled-shroud.stl # OLED 遮光罩
│ ├── button-cap.stl # 按鍵帽
│ └── foot-seat.stl # 矽膠腳墊座
├── f3d/
│ └── warmer-design.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件