📋 專案概述

女兒房間的冷氣是 2015 年的窗型機——冷凍好用到像用噴的,智能到像用刻的:全機唯一的「定時」是一個塑料轉輪。真正的問題從夏天開始:這隻原廠遙控器是全家唯一的一隻,它的一生在客廳坐墊下、泡牛奶的杯子裡、沙發與牆的縫隙之間循環。你曾在凌晨一點被女兒敲門叫起來——「媽媽把冷氣關掉,我冷」——而你摸黑找遙控器的十四分鐘裡,她已經咳了兩次。隔天你在購物平臺下單一個智能型冷氣夥伴,NT$690:設定要註冊賬號、綁卡、連雲端;第三天凌晨它「雲端連線中」,你設的凌晨 2 點定時冷氣照跑一整夜;最絕的是設定頁要你選「冷氣機廠牌+型號」,你這臺是建商統一裝的冷門牌,查無此機——一顆會發紅外線的塑料都不肯賣給你。退貨到期前一天你把它收了。你其實只要四件事:你下班到家前 20 分鐘預冷(9 月的房間能悶到 31 度,女兒進門那一秒不該像進烤箱);凌晨兩點如果室溫掉到 25.5 度以下,自動停掉製冷改除溼——小孩的被子永遠踢不開她的鼻涕;它壞掉的時候要知道(壓縮機沒理它比沒冷氣更可怕);以及,月底你想知道這臺「不智能」的冷氣這個月到底跑了多少小時——該清濾網了。

而這一切的技術核心只有一個詞:學習。不查碼庫、不問廠牌、不連廠商的雲——把原廠遙控器對準這個 NT$15 的接收器按一下,它把那段紅外光逐微秒錄下來,之後原樣重播。你的遙控器會什麼,它就會什麼;你的遙控器只有「26 度製冷」這一個按鈕夠用,那就學那一個。

市售商品:

本計畫功能清單:

💰 成本效益:全機約 NT$450,比 NT$690 的雲端款還便宜,還不看任何人臉色。一臺窗型冷氣一夏天多喘的冤孽電費約 NT$400–700(整夜過度製冷+忘關)——本計畫一個夏天回本,附帶治好「凌晨一點找遙控器」這個家庭慢性病。

爲什麼選 ESP32-C3?

需求C3 的優勢
紅外捕獲要硬實時RMT 硬件外設自己收發波形,CPU 全程睡覺——單核的 C3 幹這活綽綽有餘,換成雙核純屬浪費
角落部署成本敏感這機器註定要「每個有老家電的房間放一臺」:DevKitM-1 僅 NT$110,SuperMini 更便宜;三臺才 NT$330,比一臺雲端款的一半還少
24/7 長期通電C3 是系列裡的省電選手:輕載 60–80mA@5V,全年電費約 NT$200——比雲端中控的「雲端訂閱隱形成本」誠實
不需要藍牙/USB配置走 Wi-Fi+序列埠就夠;C3 原生 USB 留給 Serial 調試,不佔 UART

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C3 SuperMini1~110 元燒錄要手按 BOOT(見組裝步驟);DevKitM-1 更新手友善
紅外接收VS1838B 一體式接收頭1~15 元內建 38kHz 解調,OUT 直接進 GPIO
紅外發射940nm 對射管 ×2+S8050+1kΩ1 組~15 元兩顆串聯增大發射角;加 47Ω 限流
溫溼度AHT21 I2C1~60 元精度 ±0.3°C,夠判 25.5°C 規則線
面板SSD1306 0.96 吋 OLED1~90 元教學槽位/規則狀態的離線 UI
散熱風扇3010 靜音風扇(5V)1~40 元量溫前吹 10 秒趕走紅積熱——AHT21 誠實的秘密
電源5V 1.5A adapter+DC 母座1~60 元長期通電,選大廠,別用雜貨 5V/1A
列印材料PLA 約 28g—~25 元壁掛座+IR 導光筒+隔熱板
小件輕觸開關、2SC1815+1k、杜邦短線—~35 元—
合計~450 元雲端款的 65%,還不用看雲端的臉色

🔌 接線圖

ESP32-C3 5=IR RX(VS1838B) 6=IR TX(PWM) 3=學習按鍵 0=SDA 1=SCL 7=風扇(S8050) 5V←adapter VS1838B 接收頭 VCC→3V3 OUT→GPIO5 GND ┆ 學習時對準原遙控器 IR LED×2 ┆對準冷氣 GPIO6→1k→S8050 基極 LED串+47Ω→5V, C→集電極 OLED+AHT21(I2C) SDA←0 SCL←1(同總線) AHT21 地址 0x38 AHT21 掛隔熱板、遠離板子 3010 風扇 GPIO7→1k→S8050, 風扇→5V 學習 GPIO3 → GND(長壓 1.5s 進教學模式) 避開 strapping:GPIO2/8/9 空置
▲ 全機 14 根線。兩個「裝對位置」比接線更重要:IR LED 要能「看見」冷氣遙控窗,AHT21 要「看不見」電路板的溫度。

詳細接線

ESP32-C3 接腳連接說明
GPIO5VS1838B OUTRMT RX+38kHz 載波偵測;高電平空閒
GPIO61kΩ → S8050 基極RMT TX 出 38kHz 載波;LED 串(+47Ω)接 5V—集電極
GPIO0 / GPIO1I2C SDA / SCLOLED(0x3C)與 AHT21(0x38)共總線
GPIO3學習按鍵 → GNDINPUT_PULLUP;長按 1.5s 進教學模式
GPIO71kΩ → S8050 → 3010 風扇量溫前吹 10 秒,排走板子積熱
5V / GNDadapterLED 與風扇的 5V 從端子臺分出,別細線串主板 VIN
ESP32-C3 SuperMini 外設 GPIO5 ←───── VS1838B OUT(VCC→3V3,高電平空閒) GPIO6 ──1k─► S8050 ─► [940nm×2 串+47Ω] ─► 5V (發射:對準冷氣遙控窗) GPIO0 ←──┬── OLED SDA / AHT21 SDA (I2C 共總線) GPIO1 ←──┴── OLED SCL / AHT21 SCL GPIO3 ←───── 學習輕觸 → GND GPIO7 ──1k─► S8050 ─► 3010 風扇 → 5V (量溫前吹 10s 趕積熱) 5V/GND ←───── 5V 1.5A adapter(LED/風扇自端子臺分電) ※ GPIO2(strap)/GPIO9(BOOT) 空置;GPIO8 只作板載指示燈不作外設

🖨️ 3D 列印設計

零件材質/設定數量備註
wall-mount 壁掛座PLA/0.28mm/2 壁/18%1鎖在冷氣機正對面 1.5–3m 的牆上(紅外可視角內);背後兩孔 5.5mm 間距掛螺絲
case-body 主機殼PLA/0.24mm/2 壁/20%1OLED 窗+學習鍵孔+底部走線槽;接收頭與發射頭各開一窗在同側
ir-beam 導光筒黑色 PLA/0.2mm/2 壁1套住 IR LED 的 12mm 深筒——防漫反射回授,讓 3m 外的遙控窗「只吃得下直射光」
sensor-island 隔熱島PLA/0.36mm/1 壁1把 AHT21 抬離主板 15mm 的立島+擋風翼——它是本機精度的守門員
fan-duct 風扇導風罩PLA/0.28mm/1 壁/15%1把 3010 的風精準導到 AHT21 上,不吹整個殼(吹整個殼=量到風扇的哲學溫度)

列印總重 ~28g,0.4 噴嘴一氣呵成。外殼務必避開冷氣出風口直吹路徑——你量的是房間溫度,不是出風口溫度;兩者夏天能差 8°C,規則會全部失真。

💻 Arduino 程式碼

Arduino IDE 2.x+espressif 3.x core(開發板 ESP32C3 Dev Module,CDC On);庫:Adafruit SSD1306/Adafruit AHTX0/PubSubClient/ArduinoJson;文件系統 LittleFS(core 3.x 內建,IR 原始碼槽存這裡,NVS 只放 Wi-Fi 與 MQTT 設置——別用 Preferences 存波形,20KB NVS 裝不下你的全銀河遙控碼)。

/* ============================================================
 *  老冷氣紅外學習中控  ir-ac-master.ino
 *  ------------------------------------------------------------
 *  功能:原始紅外碼學習 6 槽(LittleFS)、預冷/防過冷/失聯看門狗
 *        三規則本地引擎、OLED 面板、MQTT 日報
 *  硬體:ESP32-C3 SuperMini|VS1838B=GPIO5|IR TX=GPIO6(38kHz)
 *        OLED 0x3C+AHT21 0x38 共 I2C(SDA=0 SCL=1)
 *        學習鍵=GPIO3|3010 風扇=GPIO7(經 S8050)
 *  供電:5V 1.5A adapter 長期通電
 *  相依:core 3.x(新 RMT 驅動)、Adafruit SSD1306/AHTX0、
 *        PubSubClient、ArduinoJson、LittleFS
 * ============================================================ */
#include <WiFi.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_AHTX0.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <time.h>
#include <driver/rmt_tx.h>
#include <driver/rmt_rx.h>

/* --- 0. 配置 --- */
const char* WIFI_SSID  = "YOUR_SSID";
const char* WIFI_PASS  = "YOUR_PASS";
const char* MQTT_HOST  = "192.168.8.60";
const char* MQTT_TOPIC = "beruup/downstage/ir-ac";
#define SLOT_LABEL 6             // LittleFS 槽檔=/s0.ir../s5.ir,名稱見 SLOT_CN
const char* SLOT_CN[SLOT_LABEL] = {"26冷", "27冷", "除溼", "送風", "擺風關", "關機"};
#define BTN_PIN 3
#define FAN_PIN 7
#define RES_US      10          // 量化刻度:10µs(38kHz 半週期 13µs 之下仍安全)
#define MAX_EDGE    1400        // 單幀上限:1400 邊≈覆蓋市售長碼的 99%
#define GAP_CAP_US  12000       // 間隔封頂 12ms(超過=幀結束)
void uiLearnHint();
void mqttAlarm(const char* why);

/* --- 1. OLED 與溫溼度 --- */
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
Adafruit_AHTX0 aht;
float tempC = 0, humiPct = 0;

/* --- 2. RMT 紅外收發(core 3.x 新驅動)---
 * rx:硬件捕獲 VS1838B 波形→gEdge[](µs,16bit 封頂 12ms)
 * tx:gEdge[]→rmt_symbol_word_t 序列回放,載波 38kHz 由硬件調製
 * 爲什麼量化到 10µs:16bit×10µs=655ms 上限綽綽有餘,槽位檔案省一半 */
uint16_t gEdge[MAX_EDGE];
uint16_t edgeCnt = 0;
volatile bool capDone = false;
rmt_channel_handle_t rxCh, txCh;
rmt_symbol_word_t rxSym[1400];
static bool rxDoneCb(rmt_channel_handle_t ch, const rmt_rx_done_event_data_t* ed, void* usr) {
  // RMT RX 每個 symbol 兩個半段:非零者各是一條邊(duration1 只在半段>16bit 時啟用)
  for (size_t i = 0; i < ed->num_symbols && edgeCnt < MAX_EDGE; i++) {
    if (rxSym[i].duration0) gEdge[edgeCnt++] = min((uint32_t)rxSym[i].duration0, (uint32_t)GAP_CAP_US);
    if (rxSym[i].duration1) gEdge[edgeCnt++] = min((uint32_t)rxSym[i].duration1, (uint32_t)GAP_CAP_US);
  }
  capDone = true;
  return false;
}
void irInit() {
  rmt_rx_channel_config_t rc = {};
  rc.gpio_num = 5; rc.clk_src = RMT_CLK_SRC_DEFAULT;
  rc.resolution_hz = 1000000; rc.mem_block_symbols = 64; rc.invert_signal = false;
  rmt_new_rx_channel(&rc, &rxCh);
  rmt_rx_event_callbacks_t cbs = {};
  cbs.on_recv_done = rxDoneCb;
  rmt_rx_register_event_callbacks(rxCh, &cbs, NULL);
  rmt_tx_channel_config_t tc = {};
  tc.gpio_num = 6; tc.clk_src = RMT_CLK_SRC_DEFAULT;
  tc.resolution_hz = 1000000; tc.mem_block_symbols = 64; tc.trans_queue_depth = 2;
  rmt_new_tx_channel(&tc, &txCh);
  rmt_carrier_config_t cc = {};
  cc.frequency_hz = 38000; cc.duty_cycle = 0.33f;
  rmt_apply_carrier(txCh, &cc);                              // 載波=硬件調製,CPU 零參與
  rmt_enable(rxCh); rmt_enable(txCh);
}
/* 捕獲一幀:間隔 >12ms 即收尾;timeoutMs 內無信號=失敗。回傳:邊數 */
uint16_t irCapture(uint32_t timeoutMs) {
  edgeCnt = 0; capDone = false;
  rmt_receive_config_t rcv = {};
  rcv.signal_range_min_ns = 10000;          // <10µs=噪聲,硬件直接忽略
  rcv.signal_range_max_ns = (uint32_t)GAP_CAP_US * 1000;   // 超長間隔=結束並觸發回調
  if (rmt_receive(rxCh, rxSym, sizeof rxSym, &rcv) != ESP_OK) return 0;
  uint32_t t0 = millis();
  while (!capDone && millis() - t0 < timeoutMs) delay(5);
  rmt_disable(rxCh); rmt_enable(rxCh);      // 無論成敗都重新武裝(兼作超時取消)
  return capDone ? edgeCnt : 0;
}
/* 回放當前 gEdge:mark/space 交替,硬件自己調製 38kHz */
void irReplay() {
  if (!edgeCnt) return;
  static rmt_symbol_word_t sym[MAX_EDGE];
  size_t n = 0; bool mark = true;
  for (uint16_t i = 0; i < edgeCnt && n < MAX_EDGE - 2; i++) {
    uint32_t us = (uint32_t)gEdge[i];
    while (us > 60000 && n < MAX_EDGE - 2) {  // symbol 半段上限 65535µs→拆分同位元組
      sym[n].duration0 = mark ? 60000 : 0; sym[n].duration1 = mark ? 0 : 60000;
      sym[n].level0 = sym[n].level1 = mark; n++;
      us -= 60000;
    }
    sym[n].duration0 = mark ? (uint16_t)us : 0;
    sym[n].duration1 = mark ? 0 : (uint16_t)us;
    sym[n].level0 = sym[n].level1 = mark; n++;
    mark = !mark;
  }
  rmt_transmit_config_t txc = {};
  txc.loop_count = 0; txc.flags.eot_level = 1;                // 結束電平=滅(不幹擾別人)
  rmt_transmit(txCh, sym, n * sizeof(rmt_symbol_word_t), &txc);
  rmt_tx_wait_all_done(txCh, 3000);
}

/* --- 3. 槽位檔案(LittleFS,10µs 刻度=檔案省一半)---
 * 參數:slot=0..5;回傳:true=讀/寫成功 */
bool slotSave(uint8_t slot) {
  File f = LittleFS.open(String("/s") + slot + ".ir", "wb");
  if (!f) return false;
  uint16_t t = 0;
  f.write((uint8_t*)&edgeCnt, 2);
  for (uint16_t i = 0; i < edgeCnt; i++) {
    t = gEdge[i] / RES_US;                    // µs→10µs 刻度(±5µs 誤差,紅外容差 10%)
    f.write((uint8_t*)&t, 2);
  }
  f.close(); return true;
}
bool slotLoad(uint8_t slot) {
  File f = LittleFS.open(String("/s") + slot + ".ir", "rb");
  if (!f) return false;
  f.read((uint8_t*)&edgeCnt, 2);
  uint16_t t;
  for (uint16_t i = 0; i < edgeCnt && i < MAX_EDGE; i++) {
    if (f.read((uint8_t*)&t, 2) != 2) { f.close(); return false; }
    gEdge[i] = (uint16_t)t * RES_US;
  }
  f.close(); return true;
}
bool slotExists(uint8_t s) { return LittleFS.exists(String("/s") + s + ".ir"); }
void sendSlot(uint8_t s) { if (slotLoad(s)) { irReplay(); beepLed(); } }
uint32_t lastTrigAt = 0; uint8_t trigCount = 0;
void beepLed() { digitalWrite(8, HIGH); delay(80); digitalWrite(8, LOW); }   // 板載 RGB 的紅燈腳

/* --- 4. 教學模式:長壓 1.5s 進入,逐槽位「對準→按原遙控器」---
 * 短壓=測試回放該槽;收到原碼=學習並前進下一槽;12s 無動作=離開 */
uint8_t learnMode = 0, learnSlot = 0; uint32_t learnAt = 0;
uint32_t btnDownAt = 0; bool btnWas = true;
void btnTask() {
  bool now = digitalRead(BTN_PIN);
  if (now == LOW && btnWas) btnDownAt = millis();           // 按下沿
  if (now == HIGH && !btnWas) {                              // 放開沿
    uint32_t held = millis() - btnDownAt;
    if (held > 1500) { learnMode = 1; learnSlot = 0; learnAt = millis(); }
    else if (learnMode) { sendSlot(learnSlot); }             // 教學模式短壓=試放
  }
  btnWas = now;
}
void learnTick() {
  if (!learnMode) return;
  if (millis() - learnAt > 12000) { learnMode = 0; return; } // 超時退出
  if (irCapture(250) >= 60) {                                // ≥60 邊=像個正經幀
    if (slotSave(learnSlot)) learnSlot = (learnSlot + 1) % SLOT_LABEL;
    learnAt = millis();
    uiLearnHint();
  }
  // 每 250ms 輪詢捕獲=簡單可靠;RMT 硬件捕獲期間波形不丟
}

/* --- 5. 規則引擎:預冷/防過冷/失聯看門狗(純本地,斷網照跑)--- */
bool todayPrecooled = false;
bool coolingNow = false;                 // 本機製冷「意圖」旗標
uint32_t lastTempSampleAt = 0, watchFrom = 0;
float tempAtWatch = 0, tMin = 99, tMax = -99;
void fanBlow(uint32_t ms) {              // 量溫前趕走 AHT21 周圍的板子積熱
  digitalWrite(FAN_PIN, HIGH); delay(ms); digitalWrite(FAN_PIN, LOW);
}
void readSensorHonest() {                // 參數:無|回傳:經風扇整流的真實溫溼度→tempC/humiPct
  fanBlow(2500);                         // 導風罩有效→吹 2.5s 夠置換傳感器周邊空氣
  sensors_event_t t, h; aht.getEvent(&t, &h);
  tempC = t.temperature; humiPct = h.relative_humidity;
}
void rulesTick() {
  tm tmv;
  if (!getLocalTime(&tmv, 50)) return;   // NTP 未同步=跳過規則(寧可不跑不亂跑)
  if (millis() - lastTempSampleAt < 60000) return;
  lastTempSampleAt = millis();
  readSensorHonest();
  tMin = min(tMin, tempC); tMax = max(tMax, tempC);

  int nowMin = tmv.tm_hour * 60 + tmv.tm_min;
  bool weekday = tmv.tm_wday >= 1 && tmv.tm_wday <= 5;
  // 規則 1:平日 17:40 預冷——室溫 ≥29 且今天還沒開過
  if (weekday && nowMin >= 17 * 60 + 40 && nowMin < 18 * 60 + 10
      && !todayPrecooled && tempC >= 29.0f) {
    if (slotExists(0)) { sendSlot(0); coolingNow = true; todayPrecooled = true;
      lastTrigAt = millis(); trigCount++; }
  }
  if (nowMin < 60) todayPrecooled = false;                   // 午夜重置
  // 規則 2:凌晨 02–06 防過冷——≤25.5°C 切除溼,滯回 45 分鐘
  if (tmv.tm_hour >= 2 && tmv.tm_hour < 6 && coolingNow
      && tempC <= 25.5f && millis() - lastTrigAt > 45UL * 60000) {
    if (slotExists(2)) { sendSlot(2); coolingNow = false;
      lastTrigAt = millis(); trigCount++; }
  }
  // 規則 3:失聯看門狗——發過開機碼 30 分鐘,室溫沒降 0.3°C=遙控沒被聽話
  if (coolingNow && watchFrom == 0) { watchFrom = millis(); tempAtWatch = tempC; }
  if (coolingNow && millis() - watchFrom > 30UL * 60000 && tempC > tempAtWatch - 0.3f)
    { mqttAlarm("AC 失聯:遙控沒執行?電池/對準/髒污"); watchFrom = 0; coolingNow = false; }
  if (coolingNow && tempC < tempAtWatch - 0.3f) watchFrom = 0;  // 降溫了=關閉警報窗口
}

/* --- 6. OLED 面板:四頁輪換(時間/規則/槽位/學習提示)--- */
uint8_t uiPage = 0; uint32_t uiAt = 0; bool uiLearnHintFlag = false;
void uiLearnHint() { uiLearnHintFlag = true; uiPage = 3; uiAt = 0; }
void uiDraw() {
  oled.clearDisplay();
  char ln[34];
  if (uiPage == 0) {
    tm t; if (getLocalTime(&t, 0)) strftime(ln, sizeof ln, "%H:%M", &t); else strcpy(ln, "--:--");
    oled.setCursor(0, 0);  oled.setTextSize(2); oled.print(ln);
    oled.setCursor(0, 32); oled.setTextSize(1);
    oled.printf("%.1fC  %s", tempC, coolingNow ? "COOL" : "idle");
  } else if (uiPage == 1) {
    oled.setTextSize(1);
    oled.setCursor(0, 0);  oled.printf("預冷:%s 今日已觸發", todayPrecooled ? "done" : "待命");
    oled.setCursor(0, 12); oled.printf("防過冷:%s 滯回45m", coolingNow ? "製冷中" : "安全");
    oled.setCursor(0, 24); oled.printf("觸發:%u  今:%.1f~%.1fC", trigCount, tMin, tMax);
  } else if (uiPage == 2) {
    oled.setTextSize(1); oled.setCursor(0, 0); oled.print("槽位:");
    for (int i = 0; i < SLOT_LABEL; i++) {
      oled.setCursor((i % 3) * 43, 16 + (i / 3) * 12);
      oled.printf("%s%s ", slotExists(i) ? "●" : "○", SLOT_CN[i]);
    }
  } else {
    oled.setTextSize(1);
    oled.setCursor(0, 4);  oled.printf("學習槽 %u: %s", learnSlot, SLOT_CN[learnSlot]);
    oled.setCursor(0, 24); oled.print("對準→按原遙控器");
    oled.setCursor(0, 40); oled.print("短壓=試聽 12s 離開");
  }
  oled.display();
}

/* --- 7. MQTT:日報/警報/心跳 --- */
WiFiClient esp;
PubSubClient mq(esp);
void mqttPub(const char* json) {
  if (WiFi.status() != WL_CONNECTED || !mq.connected()) return;
  mq.publish(MQTT_TOPIC, (const uint8_t*)json, strlen(json), true);
}
void mqttAlarm(const char* why) {
  StaticJsonDocument<192> d;
  d["event"] = "alert"; d["device"] = "ir-ac"; d["why"] = why;
  char buf[192]; size_t n = serializeJson(d, buf);
  if (WiFi.status() == WL_CONNECTED) { if (!mq.connected()) mq.connect("ir-ac"); }
  if (mq.connected()) mq.publish(MQTT_TOPIC, (const uint8_t*)buf, n, true);
}
void mqttDaily() {
  StaticJsonDocument<384> d;
  d["event"] = "daily"; d["device"] = "ir-ac";
  d["tmin"] = tMin; d["tmax"] = tMax;
  d["trig"] = trigCount; d["hum"] = (int)humiPct;
  d["slots"] = (uint8_t)((slotExists(0) << 0) | (slotExists(1) << 1) | (slotExists(2) << 2)
                       | (slotExists(3) << 3) | (slotExists(4) << 4) | (slotExists(5) << 5));
  tMin = 99; tMax = -99; trigCount = 0;
  char buf[384]; size_t n = serializeJson(d, buf);
  if (!mq.connected()) mq.connect("ir-ac");
  if (mq.connected()) mq.publish(MQTT_TOPIC, (const uint8_t*)buf, n, true);
}

/* --- 8. setup:先文件系統→屏幕→紅外→網--- */
void setup() {
  Serial.begin(115200);
  pinMode(BTN_PIN, INPUT_PULLUP);
  pinMode(FAN_PIN, OUTPUT); digitalWrite(FAN_PIN, LOW);
  pinMode(8, OUTPUT); digitalWrite(8, LOW);      // SuperMini 板載紅燈(GPIO8),只做指示燈
  LittleFS.begin(true);                          // 首次自動格式化
  Wire.begin(0, 1);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  oled.setTextColor(SSD1306_WHITE);
  aht.begin();
  irInit();
  WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);   // 不等待:斷網規則照跑
  mq.setServer(MQTT_HOST, 1883);
  configTime(8 * 3600, 0, "pool.ntp.org", "time.nist.gov");
}

/* --- 9. loop:教學模式 250ms 輪詢、規則每分鐘、UI 4 秒輪換 --- */
void loop() {
  static uint32_t tLearn = 0, tUi = 0, tDaily = 0;
  uint32_t m = millis();
  btnTask();
  if (m - tLearn >= 250) { tLearn = m; learnTick(); }
  rulesTick();
  if (m - tUi >= 4000) {
    tUi = m;
    if (uiLearnHintFlag) { uiLearnHintFlag = false; }
    else uiPage = (uiPage + 1) % 3;
    uiDraw();
  }
  if (WiFi.status() == WL_CONNECTED) mq.loop();
  if (m - tDaily >= 86400000UL) { tDaily = m; mqttDaily(); }
}
⚠️ 三個誠實提醒:① rmt_receive_config_t 與 rx 回調簽名以你 core 3.x 小版的 rmt_rx.h 爲準(3.0 與 3.1 結構體字段有過重排;編譯報字段名錯就照頭檔改)。② 板載紅燈腳位隨 C3 板而異(SuperMini 多爲 GPIO8 反相紅燈),不亮或常亮就調 beepLed() 的電平——它只是閃光燈,不是功能。③ IR LED 用「兩顆串聯+47Ω」而非並聯:串聯讓 5V 裕度剛好落在 2.4V×2+Vce,發射角實測量過比單顆亮一檔。
📦 NAS 端(Node-RED):日報 JSON(tmin/tmax/trig/slots 位圖)→「🌡️ 老冷氣日報:17.1–27.8°C、觸發 2 次、槽位 6/6 已學」;另按 trig 與溫度區間粗估壓縮機時數,連續兩週 >8h/日且溫差不縮 → 提醒你清濾網。警報 topic 直通 Telegram——「AC 失聯」推給你的那一刻,先去換遙控器電池,再懷疑代碼。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啓 HTML 組裝說明書
  1. 先點亮再掛牆:OLED 上電顯示 BOOT、AHT21 序列埠吐溫度——兩樣都活再往下走
  2. 發射功率測試:手機相機對準 IR LED 按回放,屏幕裡應見紫白閃光;3m 外對着另一支手機相機看到閃光=發射角健康
  3. 學習六槽位:長壓學習鍵 1.5s 進教學模式,按 OLED 提示對準 VS1838B 按原廠遙控器——「26 冷→27 冷→除溼→送風→擺風關→關機」逐槽學;槽滿會自己前進
  4. 回放實測:短壓逐槽試放,冷氣每槽都要理你;不理=接收窗被導光筒擋到,或發射 LED 沒對準冷氣遙控窗(改掛高 30cm 再試)
  5. 隔熱島校準:風扇導風罩裝上、AHT21 立島到位;與一支靠譜的溫度計並排睡一晚,差值 >0.8°C 再挪位置(避開陽光與出風口)
  6. 掛牆定位:冷氣正對面 1.5–3m、IR LED 無遮攔直視遙控窗;學習鍵朝下方便以後重學
  7. 斷網演練:拔 router 電源線跑一天——規則必須照跑(本機的命脈);恢復後才補日報
  8. 看門狗演練:對着冷氣機蓋一塊板子讓它「收不到」,30 分鐘後 Telegram 應收到「AC 失聯」——演練通過纔算交機

🔧 問題排除

問題原因解決方法
學習永遠「不像個幀」VS1838B 沒供電在 3V3,或陽光/節能燈在灌 38kHz 噪聲接收頭只吃 2.6–5.5V 但 OUT 電平看 3V3 邏輯;把機器挪離 LED 節能燈 1m 以上再試
回放冷氣不理發射 LED 沒進遙控窗可視角;或導光筒遮光角過窄用步驟 2 的「手機相機法」找可視角;導光筒內壁貼白色電工帶增漫射,別貼鋁箔(鏡面反射會躲角)
NEC 碼通、長碼不通幀長超過 MAX_EDGE=1400加大 MAX_EDGE 與 rxSym[](每 200 邊多約 400B RAM);或把該鍵拆兩段學(部分長碼協議分兩包)
規則亂觸發NTP 漂移——C3 沒 RTC 電池,斷網重啓後時間歸零本機時間以 NTP 爲準,重啓後沒連上就跳過規則(getLocalTime 50ms 超時保護);日報沒來=先看時間同步
溫度比隔壁高 1°C+AHT21 被主板積熱或陽光烤了隔熱島抬高+導風罩確認對着傳感器吹;避開西曬牆;還有問題就把 AHT21 拉短線移出殼外 10cm
除溼規則不執行你的遙控器沒有「除溼」鍵(少數窗型機)退而求其次:該槽學「27°C 製冷」代替——同樣是停壓縮機循環的替代方案
SuperMini 燒不進去它沒有自動/boot 按鈕的 USB 橋設計按下 BOOT 再點上傳,IDE 開始寫入的瞬間放開;或一次買 DevKitM-1 少生悶氣
每天日報差一格86400000ms 硬計數會慢慢漂換真 cron:每日 07:00 用 NTP 本地時間觸發;斷網期間跳過而不是補發(NAS 端去重)

🎯 進階延伸

📦 檔案清單

ir-ac-master/
├── src/
│   └── ir-ac-master.ino        # 中控韌體(本頁完整程式)
├── nas/
│   └── node-red-flow.json      # 日報接收/濾網提醒/失聯警報
├── stl/
│   ├── wall-mount.stl          # 壁掛座
│   ├── case-body.stl           # 主機殼(接收/發射同側開窗)
│   ├── ir-beam.stl             # IR 導光筒(黑 PLA)
│   ├── sensor-island.stl       # AHT21 隔熱島
│   └── fan-duct.stl            # 風扇導風罩
├── f3d/
│   └── irac-design.f3d         # Fusion 360 原始檔
├── test/
│   └── ir-loopback.ino         # 自發自收回圈測試
└── manual/
    └── assembly.html           # 圖文組裝說明書
← 上一篇:ESP32-S3 復古街機掌機 📚 系列索引 下一篇:敬請期待 →