📋 專案概述
女兒房間的冷氣是 2015 年的窗型機——冷凍好用到像用噴的,智能到像用刻的:全機唯一的「定時」是一個塑料轉輪。真正的問題從夏天開始:這隻原廠遙控器是全家唯一的一隻,它的一生在客廳坐墊下、泡牛奶的杯子裡、沙發與牆的縫隙之間循環。你曾在凌晨一點被女兒敲門叫起來——「媽媽把冷氣關掉,我冷」——而你摸黑找遙控器的十四分鐘裡,她已經咳了兩次。隔天你在購物平臺下單一個智能型冷氣夥伴,NT$690:設定要註冊賬號、綁卡、連雲端;第三天凌晨它「雲端連線中」,你設的凌晨 2 點定時冷氣照跑一整夜;最絕的是設定頁要你選「冷氣機廠牌+型號」,你這臺是建商統一裝的冷門牌,查無此機——一顆會發紅外線的塑料都不肯賣給你。退貨到期前一天你把它收了。你其實只要四件事:你下班到家前 20 分鐘預冷(9 月的房間能悶到 31 度,女兒進門那一秒不該像進烤箱);凌晨兩點如果室溫掉到 25.5 度以下,自動停掉製冷改除溼——小孩的被子永遠踢不開她的鼻涕;它壞掉的時候要知道(壓縮機沒理它比沒冷氣更可怕);以及,月底你想知道這臺「不智能」的冷氣這個月到底跑了多少小時——該清濾網了。
而這一切的技術核心只有一個詞:學習。不查碼庫、不問廠牌、不連廠商的雲——把原廠遙控器對準這個 NT$15 的接收器按一下,它把那段紅外光逐微秒錄下來,之後原樣重播。你的遙控器會什麼,它就會什麼;你的遙控器只有「26 度製冷」這一個按鈕夠用,那就學那一個。
市售商品:
- 智能型冷氣夥伴/紅外中控:NT$690–1,500,吃雲端+吃碼庫(冷門機查無此機)+吃訂閱;斷網變磚、廠商收攤變磚、你搬家它也要「重新配網證明自己還活着」。
- 換代買分離式新冷氣附智能:能解決一切問題,預算 NT$28,000,以及你會開始懷念窗型機那種野蠻的冷。
- 米家小愛+萬能遙控:NT$300 起,但同樣是碼庫制——你的建商牌冷氣在它眼裡不存在。
本計畫功能清單:
- 原始碼學習 6 槽位:VS1838B+RMT 硬件捕獲,逐 10µs 記錄整段紅外波形存 LittleFS——NEC/NEC 擴展/奇翼長碼/未知協議通吃,學一次永久回放
- 本地規則引擎,斷網照跑:平日 17:40 室溫 ≥29°C 且今日未開機 → 回放「26°C 製冷」;02:00–06:00 室溫 ≤25.5°C 且正在製冷 → 回放「除溼」;滯回 45 分鐘不震盪
- 壓縮機失聯看門狗:發了開機碼但室溫 30 分鐘沒降 0.3°C → Telegram 報警「遙控器電池沒電/冷氣故障」——它甚至能發現遙控器的電池死了
- 0.96 吋 OLED 面板:時間/室溫/規則狀態/槽位圖循環顯示,長按學習鍵進教學模式,全家無需 App
- NAS 日報:每日 07:00 推溫度區間、觸發計數、粗估壓縮機時數(溫度斜率推斷)——濾網該不該清,數據說話
爲什麼選 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 SuperMini | 1 | ~110 元 | 燒錄要手按 BOOT(見組裝步驟);DevKitM-1 更新手友善 |
| 紅外接收 | VS1838B 一體式接收頭 | 1 | ~15 元 | 內建 38kHz 解調,OUT 直接進 GPIO |
| 紅外發射 | 940nm 對射管 ×2+S8050+1kΩ | 1 組 | ~15 元 | 兩顆串聯增大發射角;加 47Ω 限流 |
| 溫溼度 | AHT21 I2C | 1 | ~60 元 | 精度 ±0.3°C,夠判 25.5°C 規則線 |
| 面板 | SSD1306 0.96 吋 OLED | 1 | ~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 接腳 | 連接 | 說明 |
|---|---|---|
| GPIO5 | VS1838B OUT | RMT RX+38kHz 載波偵測;高電平空閒 |
| GPIO6 | 1kΩ → S8050 基極 | RMT TX 出 38kHz 載波;LED 串(+47Ω)接 5V—集電極 |
| GPIO0 / GPIO1 | I2C SDA / SCL | OLED(0x3C)與 AHT21(0x38)共總線 |
| GPIO3 | 學習按鍵 → GND | INPUT_PULLUP;長按 1.5s 進教學模式 |
| GPIO7 | 1kΩ → S8050 → 3010 風扇 | 量溫前吹 10 秒,排走板子積熱 |
| 5V / GND | adapter | LED 與風扇的 5V 從端子臺分出,別細線串主板 VIN |
🖨️ 3D 列印設計
| 零件 | 材質/設定 | 數量 | 備註 |
|---|---|---|---|
| wall-mount 壁掛座 | PLA/0.28mm/2 壁/18% | 1 | 鎖在冷氣機正對面 1.5–3m 的牆上(紅外可視角內);背後兩孔 5.5mm 間距掛螺絲 |
| case-body 主機殼 | PLA/0.24mm/2 壁/20% | 1 | OLED 窗+學習鍵孔+底部走線槽;接收頭與發射頭各開一窗在同側 |
| 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,發射角實測量過比單顆亮一檔。🏗️ 組裝步驟
- 先點亮再掛牆:OLED 上電顯示 BOOT、AHT21 序列埠吐溫度——兩樣都活再往下走
- 發射功率測試:手機相機對準 IR LED 按回放,屏幕裡應見紫白閃光;3m 外對着另一支手機相機看到閃光=發射角健康
- 學習六槽位:長壓學習鍵 1.5s 進教學模式,按 OLED 提示對準 VS1838B 按原廠遙控器——「26 冷→27 冷→除溼→送風→擺風關→關機」逐槽學;槽滿會自己前進
- 回放實測:短壓逐槽試放,冷氣每槽都要理你;不理=接收窗被導光筒擋到,或發射 LED 沒對準冷氣遙控窗(改掛高 30cm 再試)
- 隔熱島校準:風扇導風罩裝上、AHT21 立島到位;與一支靠譜的溫度計並排睡一晚,差值 >0.8°C 再挪位置(避開陽光與出風口)
- 掛牆定位:冷氣正對面 1.5–3m、IR LED 無遮攔直視遙控窗;學習鍵朝下方便以後重學
- 斷網演練:拔 router 電源線跑一天——規則必須照跑(本機的命脈);恢復後才補日報
- 看門狗演練:對着冷氣機蓋一塊板子讓它「收不到」,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 端去重) |
🎯 進階延伸
- 本地網頁遙控器:加 ESPAsyncWebServer 提供一頁六按鈕——全家連 App 都不用裝,手機瀏覽器就是遙控器;怕生的可加 PIN
- 人 Presence 聯動:辦公室/臥室 Wi-Fi 終端事件接規則——「人離家 10 分鐘自動關機」,老冷氣第一次有了「無感智能」;Wi-Fi 探針改藍牙信標更佳
- 全宅紅外矩陣:NT$110×N 臺部署到每臺老家電+一個 MQTT topic 收攏;Node-RED 做「觀影模式=電視+冷氣+燈帶」的場景串聯——全是「原始碼學習」,零碼庫、零雲端
- 電費審計:冷氣插座加裝 CT 電力監測(同 NAS MQTT),「意圖 vs 實際功率」交叉驗證:發了開機碼沒電流=壓縮機壞了,提前兩週發現
📦 檔案清單
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 # 圖文組裝說明書