📋 專案概述

遠端工作第四年,我的桌面行為模式沒變過:10:30 泡好一杯凍頂,開會、回信、趕報告,11:15 想起那杯茶——摸下去是涼的。「茶涼了再微波」聽起來沒損失,但一年下來倒掉的茶、重燒的水、還有對著馬克杯皺眉的次數,早就超過一杯茶本身價值的幾百倍。問題從來不是茶,是「剛好入口的溫度」這件事沒人幫你守。

市售恆溫杯墊我們拆過三款,各有各的死法:

  1. 無溫控純發熱款(USB,約 350 元):5V 供電功率天花板 10W,夏天能溫、冬天窗邊風一吹就涼;恆功率 55°C「恆」的是發熱片不是你的茶——杯子從滿喝到見底,溫度一路往下走。
  2. 數顯恆溫款(約 900 元):有閉環了,但溫度感測在墊子上不在杯底附近,厚杯底隔熱玻璃杯形同失準;而且它沒有傾倒斷電。
  3. 「智能」App 款(1,500 元+):把開關做進 App,溫控曲線還是上述水準——賣的是藍牙晶片不是 PID。

真正讓人半夜睡不著的場景只有一個:杯子倒了,發熱墊壓在木桌上,繼續燒。所以本專案的規格順序是反的——先保險、再溫控、最後才談智慧:

成本:整組 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 SuperMini1~90 元5V 進 5V 腳供電
發熱矽膠發熱墊 12V 18W(Ø10cm)1~90 元12V 是重點:5V 款功率不夠冬天用
開關管IRLZ44N+小散熱片1~20 元低邊切換;gate 10k 下拉必裝(見下方降額說明)
溫度NTC 100K B3950 防水探頭1~35 元埋鋁板側孔+導熱膏貼實
均溫鋁板 Ø12cm × 1mm1~40 元鑽 NTC 孔+銑/磨杯底淺溝防滑
顯示SSD1306 0.96" I2C1~90 元顯示目標/實際/功率
傾倒SW-520D 滾珠開關1~15 元豎直安裝,平裝無效
降壓MP1584 模組 12V→5V1~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 SuperMini GPIO4 (ADC) GPIO6 (PWM) 5V ← MP1584 GND GPIO20/21 I2C GPIO1 按鍵 GPIO3 傾倒 GPIO0 蜂鳴 NTC 100K+100K 分壓 3V3─╥─●─╨─GND → GPIO4 NTC 在下臂,埋鋁板孔 2A 保險絲 76°C 溫度保險絲 矽膠發熱墊 12V 18W IRLZ44N D↑ S↓ G← 源極 → 共地 GPIO6 ─ 100Ω ─┤ gate(10k 下拉 GND) 12V(+) ↓ 先過兩層保險 SSD1306 54.8C → 55 P:42% 傾倒開關(豎直)→ GPIO3/GND 按鍵→GND 蜂鳴器
▲ 12V 發熱迴路(紅)與 3.3V 邏輯電路(彩線)完全分開,兩邊只在 MOSFET 與共地交會。保險絲兩顆都串在 12V 正極側。

詳細接線

ESP32-C6 腳位連接說明
GPIO4(ADC1)NTC 分壓中點100K 上拉至 3V3、NTC 下臂到 GND;ADC 讀分壓
GPIO6(LEDC)IRLZ44N gate100Ω 串阻防振鈴+10k 下拉:開機與韌體掛掉時發熱墊必關機(靠下拉,不靠韌體)
GPIO20 / GPIO21OLED SDA / SCLI2C 0x3C
GPIO1按鍵 → GND內部上拉
GPIO3傾倒開關 → GND內部上拉;正常直立=高、翻倒瞬間翻低
GPIO0蜂鳴器 +高準位發聲
5V / GNDMP1584 輸出帶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 夠嗎?

VgsIRLZ44N 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)

零件數量材質顏色列印時間
外殼下座(電路艙+保險絲艙)1PETG / ASA淺灰~3 hr
鋁板定位圈(壓住 Ø12 鋁板)1PETG淺灰~35 min
OLED 遮光罩立柱1PETG黑~20 min
按鍵帽(耐熱造型矮帽)1PETG木紋色~10 min
輪廓矽膠腳墊座1PETG任何~15 min
🥲 血淚教學:第一代用 PLA 印,連續使用第三天外殼貼著 65°C 鋁板那側開始蠕變、定位圈塌掉、鋁板翹一邊乾燒報警狂叫。PLA 玻璃轉移溫度約 60°C,本專案的常態環境就是 45~70°C。PETG 耐 80°C、ASA 耐 95°C 還抗 UV(窗邊用),多花 5 分鐘換料,少走三天冤枉路。

設計要點

  1. 熱區/電路區分艙:MOSFET 與 MP1584 放「下風口」側,OLED 與 C6 放另一側,隔艙板開對流孔 4×3mm×6
  2. 保險絲可換艙:保險絲座鎖外壁,螺蓋打开就換,不用拆機
  3. 鋁板壓邊 1.5mm:定位圈壓住鋁板不滑移,但留熱膨脹縫隙
  4. 底部 4 顆矽膠腳墊:木桌面隔熱,也是傾倒後的第一道缓冲
  5. 走線孔與發熱墊本體 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 組裝說明書
  1. 列印零件:PETG/ASA,5 個零件;保險絲艙的螺孔記得預留
  2. 12V 迴路先焊、先驗:12V → 保險絲座 → 溫度保險絲 → 發熱墊,萬用表量通斷;此時不接 MOSFET,直接點一下發熱墊 5 秒確認會熱後拆開
  3. MOSFET 三腳:TO-220 字面朝自己、由左至右 G-D-S,散熱片+導熱墊片鎖好;gate 100Ω 串、10k 下拉
  4. NTC 埋板:鋁板側孔 4mm 塞入探頭頭部+導熱膏,膠槍封孔固定;探頭歪斜=溫控失準
  5. MP1584 調壓:接上 OLED+C6 後再微調輸出至 5.0V(空載調載會飄)
  6. 燒錄與安全驗收:按下面五項驗收表逐項打勾(傾倒、乾燒、過溫、保險絲、開機全關)
  7. 合殼開跑:排線離發熱墊 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 地線上

🎯 進階延伸

📦 檔案清單

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                    # 說明文件
← 上一篇:ESP32-S3 條碼盤點掃描槍 📚 系列索引 🔜 下一篇:製作中 · 敬請期待