惟家空調控制器腳本開發指南(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。

javascript
// 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 字串,例如:

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:

javascript
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 會收到:

json
{"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):已與空調控制結構整合,暫不開放腳本控制

推送狀態給端點:

javascript
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

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

javascript
// 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));
}
Antimold v3.3(F=600 正式值)

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)