惟家空調控制器腳本開發指南(HomeKit/Matter 版)
1. 適用範圍
這份指南說明惟家空調控制器內建的 JavaScript 腳本功能,讓工程師、安裝商與進階使用者自行撰寫自動化,例如「關機自動防霉」。
- 適用產品:惟家空調控制器 HomeKit 版、Matter 版(兩版腳本功能相同;Matter 端點指令僅 Matter 版適用)
- 支援空調品牌:大金、三菱重工、三菱電機,以及透過 TaiSEIA 的國際牌、日立
- 相關文件:本機 HTTP 控制請見惟家空調控制器 API
2. 腳本能做什麼
腳本在控制器本機執行,不需要雲端或手機 App 在線。
- 讀取冷氣狀態變化(開關、模式、溫度、風量、擺風、室內外溫度)
- 對冷氣下控制指令
- (Matter 版)與 Apple 家庭的 Matter 端點同步狀態
- 對區網內其他設備發送 HTTP 請求
- 用約 512 bytes 的 state 記住流程進度
執行模型(最重要的觀念)
腳本不是一直在跑的程式,而是「被叫醒一次、從頭跑到尾、結束」。就像每次上班都會失憶的值班人員,只能靠交接本(state)知道上一班做到哪。
腳本有兩種觸發來源:
- 事件(event):冷氣狀態真的改變時觸發(或 Matter 版收到 iOS 操作)。腳本可讀到 event.data。
- 計時器(timer):依設定的間隔週期觸發(建議 1000 ms)。此時沒有 event,用來看時間、推進流程、送指令。
每次執行的順序固定:讀 state → 判斷是事件還是計時器 → 做事 → 寫回 state。沒有寫回的變數,下一次就不見了。
3. 快速開始
每支腳本都從這個骨架開始:安全讀取 state、分流事件與計時器、最後寫回 state。
// my script v26.10.25.01
var r=app.get(),s=null;
try{s=r?JSON.parse(r):{}}catch(e){s=null;print("bad state")}
if(s){ // state 壞掉就跳過這次、不動它(見 5.4,絕不清全部)
if(!s.my)s.my={ph:"idle",t:0}; // 用自己的命名空間,欄位名保持簡短
var m=s.my;
var now=Math.floor(Date.now()/1000);
if(typeof event!="undefined"&&event&&event.data){
var i=null;
try{i=JSON.parse(event.data)}catch(e2){i=null;print("parse err")}
if(i&&(i.power||i.mode)){ // 只處理冷氣狀態,忽略 Matter 事件
// 依事件切換 m.ph
app.set(JSON.stringify(s));
}
}else{
// 計時器:依 now 與 m.t 推進流程、送指令
app.set(JSON.stringify(s));
}
}上傳與啟用
- 在控制器網頁開啟「腳本執行管理」,上傳 .js 檔或新增後貼上程式碼。
- 編輯執行器設定:「事件觸發」與「計時器」兩個都要勾,執行間隔選 1000ms。少勾事件觸發,腳本不知道冷氣狀態改變;少勾計時器,腳本無法倒數與送指令。
- 用卡片上的開關啟用腳本。
- 開啟日誌頁面,用 print() 的輸出確認每一步。
- 需要清除舊狀態時,先執行一次只有 app.set("{}"); 的腳本。
🔴 檔名規則:上傳的 .js 檔名(含 .js)最多 16 個字,只能用英文字母、數字、底線 _,不能有減號 - 或多餘的點 .。例如 2026-10-25-shopee-scheduled-off.js(34 字、又有 -)會被拒,改成像 shop_off.js。
4. 腳本 API 參考
腳本可使用的介面只有:app.get、app.set、app.cmnd、event.data、http、print。
4.1 內建物件與函式
- app.get():讀 state。回傳字串,可能是空值或壞掉的 JSON,一律用 try…catch 包起來。
- app.set(str):寫 state。上限約 512 bytes。
- app.cmnd(str):下控制指令(唯一控制入口,不支援 sendCmd())。
- event.data:事件內容(JSON 字串),只在事件觸發時存在。
- http.get(url, timeoutMs):發送 HTTP GET。
- http.post(url, body, contentType):發送 HTTP POST。
- print(...):寫入日誌,可傳多個參數。
- Date.now():取得毫秒時間,用 Math.floor(Date.now()/1000) 取秒。
4.2 冷氣狀態事件
冷氣狀態改變時,event.data 是 JSON 字串,例如:
{"power":"on","mode":"cool","temp":"26","fan":"auto","swingv":"off","swingh":"off"}欄位說明:
- power:on、off
- mode:auto、cool、heat、dry、fan_only
- temp:16–30(°C),注意是字串,比大小前先 parseInt(i.temp,10)
- fan:auto、min、low、medium、high、max
- swingv:on、off(上下擺風)
- swingh:on、off(左右擺風,依機種支援)
- intemp、outtemp:室內、室外溫度(唯讀)
🔴 事件只帶有變化的欄位。例如冷氣已經開著、只改模式時,event.data 只有 mode,沒有 power。判斷「冷氣是否開著」請寫 i.power!=="off",不要寫 i.power==="on",否則會誤判。
4.3 冷氣控制指令
把要改的欄位放進 ac 物件,序列化後交給 app.cmnd:
app.cmnd(JSON.stringify({ac:{mode:"cool"}})); // 冷氣
app.cmnd(JSON.stringify({ac:{mode:"fan_only"}})); // 送風
app.cmnd(JSON.stringify({ac:{power:"off"}})); // 關機注意:不同品牌切換模式所需時間不同(實測約 1 秒到 9 秒)。需要「確定切好再做下一步」時,請等事件回報新模式,不要只固定等幾秒。
4.4 Matter 端點(Matter 版)
使用者在 iOS 操作已新增的 Matter 端點時,event.data 會收到:
{"cluster":"OnOff","assignid":4,"endpoint":3,"attribute":{"id":0,"onoff":1},"raw":[4,3,6,0,1]}raw 格式為 [assignid, endpoint, cluster, 屬性 id, 值]。
先看 cluster 判斷類型,再用 assignid 找到對應端點。
- OnOff(0x0006):屬性 id 0 onoff(0/1)
- FanControl(0x0202):屬性 id 0 mode、id 2 percent(設定值)、id 3 percent(目前值)
- Thermostat(0x0201):已與空調控制結構整合,暫不開放腳本控制
推送狀態給端點:
app.cmnd(JSON.stringify({matter:{cluster:"OnOff",assignid:2,attribute:{id:0,onoff:1}}}));
app.cmnd(JSON.stringify({matter:{cluster:"FanControl",assignid:1,attribute:{id:0,mode:"high"}}}));
// 多個屬性一次推:attribute 改用陣列
app.cmnd(JSON.stringify({matter:{cluster:"FanControl",assignid:1,attribute:[{id:0,mode:"off"},{id:2,percent:0},{id:3,percent:0}]}}));4.5 HTTP
var res=http.get("http://192.168.1.10/api/dev",2000);
var res2=http.post("http://192.168.1.10/api",JSON.stringify({test:"123"}),"application/json");建議只呼叫區網內設備,且不要在每秒觸發的計時器裡頻繁呼叫。
5. 使用規則與限制
引擎只支援舊版 JavaScript(ES5),腳本檔上限 4096 bytes、state 上限約 512 bytes。
5.1 容量
- 腳本檔 4096 bytes:中文註解每字佔 3 bytes,很容易超過。建議另存一份「註解版」給人看,上傳版只留必要註解。
- state 約 512 bytes:欄位名用 2–3 個字母(如 ph、cf、st),不要存陣列或歷史紀錄。
- 計時器間隔:建議 1000 ms;流程用「秒數差」計算,不要依賴觸發次數。
5.2 不支援的語法與替代寫法
- let、const、箭頭函式、模板字串 → 改用 var、function、字串相加
- 頂層 return → 改用 if … else 分支或階段旗標
- while、do … while → 交給計時器分次推進
- async/await、Promise、setTimeout → 改用 state + 計時器的時間差判斷
- module、import、export → 全部寫在同一個檔案
- 數字前導 0(010、08)→ 寫成 10、8
- sendCmd() → 改用 app.cmnd()
try…catch 可以正常使用。
5.3 行為規則
- 每次執行都是新的:變數不會保留到下一次,只有 app.set 寫進去的會。
- 讀 state 要防壞:app.get() 可能是空值或壞掉的 JSON,一律 try…catch。空值就當成新的空物件開始;解析失敗(壞掉)建議把 s 設成 null、這次跳過不動它(見 5.4),不要清全部。
- 先分辨事件或計時器:用 typeof event!="undefined"&&event&&event.data 判斷。
- 事件要過濾:事件可能是冷氣狀態,也可能是 Matter 操作;只處理自己關心的欄位。
- 事件只帶有變化的欄位:缺欄位時不要當成「關」或「錯」(見 4.2)。
- 自己送的指令會變成事件回來:不處理的話,腳本會把自己的動作當成使用者操作,造成無限迴圈(做法見 6.3)。
- 時間用秒、只算差值:Math.floor(Date.now()/1000) 取秒,流程只比較「現在 − 開始」。
- 可調整的數字放檔頭:例如 var K=10,B=3,F=600,R=15;,並註明單位。
5.4 多支腳本共存
建議所有腳本共用一個 s.sys 區塊互相協調,各自的私有狀態放在自己的命名空間(例如防霉用 s.am),整份 state 合計仍要小於 512 bytes。
- s.sys.owner:目前由哪支腳本接管冷氣,空字串代表沒有
- s.sys.expectUntil:有腳本剛送出指令,在此時間前其他腳本忽略事件
- s.sys.skipAntiMoldUntil:其他腳本要求防霉暫停到此時間
共用 state 的規則:
- 所有腳本共用同一份 state:一台控制器上所有腳本讀寫的是同一份 state(不是每支各一份)。正常情況(state 讀得出來)務必「整份讀 → 只改自己命名空間(如 s.am)的欄位 → 整份寫回」,不要整份覆寫掉別人的欄位。
- 🔴 讀到壞掉的 state,建議「不覆寫、跳過這次」:JSON 解析失敗代表整份 state 字串都壞了,你解析不出其他腳本的資料,也無法保留它們、只清自己。最安全的做法是 catch 時把 s 設成 null、後面用 if(s){…} 把整段流程包住,這次什麼都不做、等下次,絕不動到壞掉的 state——本指南第 3 章骨架就是這樣寫。
- 🔴 不要輕易用 app.set("{}") 清全部:清空會把所有腳本的 state 歸零。若此時有腳本正在執行多步驟流程(例如防霉正在送風、還沒送出關機指令),它的進度(如 s.am.ph)被清掉會讓流程中斷——可能造成冷氣一直送風、不關機。防霉 v3.3 讀到壞 state 時用的就是清全部,所以它若在送風途中讀到壞 state 會有這個風險(已知限制)。真要用清全部,務必知道它可能中斷進行中的流程,必要時手動確認冷氣狀態。
- ⚠️ 不覆寫會一直跳過、state 不會自己復原:用「不覆寫」的腳本若每次都讀到壞掉的 state,就會每次都跳過、不會自動恢復。要恢復請手動清一次——執行一次只有 app.set("{}") 的腳本(見第 3 章「上傳與啟用」),把壞掉的 state 清掉後,腳本下次就能重新開始。這是你主動、知情地清一次,與上面「parse 失敗時自動清全部」的危險情境不同。
- 協調時間用一致基準:若透過 state 與其他腳本協調時間(如 expectUntil),時間基準要跟既有腳本一致——防霉腳本用 (Date.now()/1000|0)+28800(台北時間)。不一致會差 8 小時、協調失效。
6. 範例:關機防霉(Antimold v3.3)
使用者關機後,自動送風 10 分鐘把蒸發器吹乾,再切回關機前的模式(冷氣/除濕/暖氣)並等冷氣確認切好才關機,讓下次用 Siri 開機時是原本的模式,而不是送風。
6.1 流程
- 使用者關機 → 進入確認期(confirming)。腳本平時會記下最後一個「不是送風」的模式(lu)。
- 持續關機 K 秒 → 確認通過,進入執行期(running)。這段時間內又開機就取消。
- 再等 B 秒 → 切到送風(fan_only)。
- 送風 F 秒 → 切回原模式(沒有紀錄時用冷氣)。
- 等冷氣回報已切回原模式,就送出關機;最多等 R 秒,超過照樣關機,不會卡住。
- 執行中使用者改模式或關機 → 立刻取消,交還控制權。
6.2 參數
- K=10 秒:關機後要持續關機多久才啟動防霉
- B=3 秒:確認通過後延遲多久切送風
- F=600 秒:送風時間(測試時可暫改 60,測完務必改回 600)
- R=15 秒:切回原模式後最多等多久再關機;冷氣回報切好就提早關機
- expectUntil=送指令後 8 秒:這段時間忽略事件,避免把自己的指令當成使用者操作
- lu:自動記錄的最後一個非送風模式
6.3 關鍵設計:分辨「自己」與「使用者」
腳本自己送的關機,冷氣一樣會回報 power:"off"。若不分辨,腳本會以為使用者又關機了,再跑一次防霉,形成無限迴圈。v3.3 用兩道保護:
- expectUntil(8 秒):送任何指令後短時間內的事件一律略過(日誌 skip by expect)。
- so 旗標:送最終關機前設 so=1,之後收到的關機回報認定是自己的(日誌 final off ack),不再觸發防霉;8 秒保護期內收到也會正確清除,冷氣被開機時舊旗標自動作廢。
6.4 測試方法
用 print() 日誌驗收,測試時把 F 暫改成 60 秒。
- 基本流程:開機 → 關機 → 等自動關機。正常日誌:off -> confirming → confirm pass → fan_only → restore mode cool → final off → final off ack
- 連續觸發:測試 1 做完後開機、再關機。第二次一樣要出現 off -> confirming 和 fan_only。
- 確認取消:關機後 10 秒內再開機。應出現 confirm cancel,且沒有 fan_only。
- 送風中斷:送風中手動改模式或關機。應出現 running cancel。
- 模式還原:用除濕或暖氣開機 → 關機 → 等自動關機。日誌應為 restore mode dry/heat。
🔴 只看日誌看不出模式有沒有切成功:restore mode ... 在送出指令當下就會印出。每輪自動關機後,請到控制器 API(見惟家空調控制器 API)確認 mode 是原模式,而不是 fan_only。
6.5 完整程式碼(v3.3)
// anti mold v3.3 (HomeKit 相容版)
// 與原版差異:送風結束後,先送 mode 切回關機前的原始模式(冷氣/除濕/暖氣等),再送 power:off 關機
// 目的:避免 HomeKit 因為冷氣機內部記憶最後模式為 fan_only,導致下次開機誤用送風模式
var s={},r=app.get();
try{s=r?JSON.parse(r):{}}catch(e){s={};app.set("{}");print("bad")}
if(!s.sys)s.sys={owner:"",expectUntil:0,skipAntiMoldUntil:0};
if(!s.am)s.am={lm:"off",lu:"",ph:"idle",cf:0,st:0,ix:0,so:0};
var m=s.am;
var K=10,B=3,F=600,R=15; // F=送風秒數(測試可改 60);R=還原模式後最多等幾秒再關機
if(typeof event!="undefined"&&event&&event.data){
var i=null;try{i=JSON.parse(event.data)}catch(e2){i=null;print("parse err")}
if(i&&(i.mode||i.power)){
var now=(Date.now()/1000|0)+28800;
m.lm=i.mode||m.lm;
// 記錄最後一次「非 fan_only」的有效模式,作為防霉結束後還原依據
if(i.mode&&i.mode!=="fan_only")m.lu=i.mode;
// v3.1:已開機就作廢舊的 so
if(i.power==="on"&&m.so===1&&now>=s.sys.expectUntil)m.so=0;
// 其他腳本剛送命令時,先忽略事件,避免誤判
// v3.1:expect 內收到自己的 final off 也要清 so
if(now<s.sys.expectUntil){
if(i.power==="off"&&m.so===1){
print("final off ack");
m.so=0;
m.ph="idle";
s.sys.owner="";
}else print("skip by expect");
}
// 外部要求暫時不要啟動防霉
else if(now<s.sys.skipAntiMoldUntil){
print("skip anti mold");
}
// 自己送出的最終 off 回報
else if(i.power==="off"&&m.so===1){
print("final off ack");
m.so=0;
m.ph="idle";
m.st=0;
m.ix=0;
m.cf=0;
m.lm="off";
s.sys.owner="";
}
// idle:偵測到關機,進確認
else if(m.ph==="idle"&&i.power==="off"){
print("off -> confirming");
m.ph="confirming";
m.cf=now+K;
m.st=0;
m.ix=0;
}
// confirming:若又開起來或被其他模式接手,取消
else if(m.ph==="confirming"){
if(i.power!=="off"){
print("confirm cancel");
m.ph="idle";
m.st=0;
m.ix=0;
m.cf=0;
m.so=0;
}
}
// running:依目前步驟判斷期望模式,其餘當作被介入,取消
// 事件只帶有變化的欄位(可能沒有 power);ix=1 期望 fan_only,ix=2 期望原模式或仍是 fan_only
else if(m.ph==="running"){
var rm=m.lu||"cool";
var ok=i.power!=="off"&&((m.ix===1&&i.mode==="fan_only")||(m.ix===2&&(i.mode===rm||i.mode==="fan_only")));
if(ok){
print("expected mode ok");
}else{
print("running cancel");
m.ph="idle";
m.st=0;
m.ix=0;
m.cf=0;
m.so=0;
s.sys.owner="";
}
}
app.set(JSON.stringify(s));
}
}else{
var now=(Date.now()/1000|0)+28800;
if(m.ph==="confirming"&&now>=m.cf){
print("confirm pass");
m.ph="running";
m.st=now;
m.ix=0;
}
if(m.ph==="running"){
var e=now-m.st;
// 步驟 1:送 fan_only 開始送風
if(m.ix===0&&e>=B){
print("fan_only");
s.sys.owner="antiMold";
s.sys.expectUntil=now+8;
app.cmnd(JSON.stringify({ac:{mode:"fan_only"}}));
m.ix=1;
}
// 步驟 2:送風結束,先把模式切回使用者原本的模式(lu,預設 cool)
if(m.ix===1&&e>=(B+F)){
var rm=m.lu||"cool";
print("restore mode "+rm);
s.sys.owner="antiMold";
s.sys.expectUntil=now+8;
app.cmnd(JSON.stringify({ac:{mode:rm}}));
m.ix=2;
}
// 步驟 3:冷氣回報已切回原模式才關機(大金約需 7 秒),最多等 R 秒
if(m.ix===2&&((m.lm===(m.lu||"cool")&&e>=(B+F+1))||e>=(B+F+R))){
print("final off");
m.so=1;
s.sys.owner="antiMold";
s.sys.expectUntil=now+8;
app.cmnd(JSON.stringify({ac:{power:"off"}}));
m.ph="idle";
m.st=0;
m.ix=0;
m.cf=0;
}
}
app.set(JSON.stringify(s));
}
7. 撰寫規範與常見錯誤
所有腳本都用狀態機寫:事件只負責「判斷發生什麼、切換階段」,計時器只負責「看時間、送指令」。
7.1 撰寫規範
- 命名空間:私有狀態放 s.<腳本縮寫>,共用協調放 s.sys。
- 階段用字串:ph 用 "idle"、"confirming"、"running",讀日誌一眼看懂。
- 每個階段都要有出口:取消、完成、逾時都要回到 idle,並清掉該階段的旗標。
- 自己送指令必設保護:送 app.cmnd 時同時設 expectUntil;要等回報的指令再加帶期限的旗標。
- 使用者優先:偵測到使用者介入就放手。
- 每個分支都 print:用簡短英文固定字串,方便對照測試結果。
- 版本與檔案:第一行寫 // <名稱> vX.Y;上傳版、註解版、測試步驟一起保存。
7.2 上線前檢查清單
- 檔案小於 4096 bytes
- 最大 state 小於 512 bytes
- 沒有 return、while、Promise、async、import、前導 0
- app.get 與 JSON.parse(event.data) 都有 try…catch
- 事件分支已過濾不相關的事件,且不依賴事件一定帶齊所有欄位
- 自己送的指令不會反過來觸發自己
- 測試用參數已改回正式值
- 6.4 的測試全部通過,並用 API 確認關機後的模式
7.3 常見錯誤
- 腳本完全沒反應:超過 4096 bytes 或用了不支援的語法 → 刪註解、對照 5.2 替換
- 流程每次都從頭開始:沒有 app.set 寫回 → 兩個分支結尾都要 app.set
- 腳本跟自己打架、無限迴圈:自己的指令回報被當成使用者操作 → 用 expectUntil + 旗標(見 6.3)
- 「隔一次才觸發」:上一輪的旗標殘留 → 旗標要能在保護期內清除、或被開機事件作廢
- 流程被誤判中斷:判斷寫成 i.power==="on",但事件沒帶 power → 改成 i.power!=="off"
- JSON.parse 報錯:收到 Matter 事件或空字串 → try…catch + 欄位檢查
- 溫度比較結果怪:temp 是字串 → 先 parseInt(i.temp,10)
