Home Assistant で家のエアコンを操作したかった。うちのダイキンは ECHONET Lite 非対応なので、赤外線でリモコンの信号を送るしかない。
赤外線でエアコンを操作する定番は SmartIR だ。温度・モード・風量の全組み合わせの赤外線コードを JSON にまとめておき、Home Assistant の climate エンティティから引く仕組みになっている。コミュニティが機種ごとの JSON を公開していて、自分の機種のものがあればそれを置くだけで動く。
ただ、なかった。
結果として、リモコンのプロトコルを解析して 2,730 行の JSON を生成することになった。その記録を残しておく。
目次
- ダイキンの赤外線プロトコルは1つではない
- 既存の JSON を全部デコードして確かめる
- キャプチャして中身を見る
- 差分でフィールドを1つずつ潰す
- 自動モードだけ様子がおかしい
- 生成して実機で検証する
- DAIKIN2 か DAIKIN312 か
- 既知のマップに載っていないもの
- まとめ
ダイキンの赤外線プロトコルは1つではない
エアコンの赤外線リモコンは、テレビのリモコンとは根本的に違う。
エアコンのリモコンは、テレビのリモコンとは違う作りのものが多い。テレビは「電源」「チャンネル+1」のようにボタンごとに独立したコードを送るが、エアコンは押すたびに現在の設定を丸ごと送る方式が広く使われている。ARC478A15 もそうだった。「26℃・冷房・風量自動・スイングON」という状態全体が1つのフレームに詰まっていて、エアコン本体には受信した状態がそのまま反映される。
この方式だと「温度+1」のような相対的なコードは存在しないので、全組み合わせぶんのコードが必要になる。5モード × 6風向 × 7風量 × 13温度 = 2,730通り。リモコンのボタンを2,730回押して学習させるのは現実的ではない。
(なお、ダイキンの中にもフレームが4〜8バイトしかない機種があり、そちらは別方式らしい。全機種がこうだというわけではない。)
さらに厄介なのが、ダイキンが世代や機種によって互換性のない赤外線プロトコルを複数使っていることだ。IRremoteESP8266 というライブラリはそれらを実装していて、DAIKIN、DAIKIN2、DAIKIN128、DAIKIN152、DAIKIN160、DAIKIN176、DAIKIN200、DAIKIN216、DAIKIN312 と名前が付いている。フレーム長も構造もバラバラだ。
つまり「ダイキン用のコード」を持ってきても、方言が違えば動かない。
既存の JSON を全部デコードして確かめる
SmartIR にはダイキン用の JSON が 21 個ある。「試してみて動くものを探す」という手もあるが、Home Assistant を再起動しながら 21 回試すのは面倒だし、動かなかったときに「方言が違うのか、設定が間違っているのか」の切り分けができない。
なので全部ダウンロードして、中身をデコードして構造を直接見た。
for c in 1100 1101 1102 ... 1119 5120; do
curl -sO "https://raw.githubusercontent.com/smartHomeHub/SmartIR/master/codes/climate/$c.json"
done
JSON に入っているのは Base64 の文字列で、これは Broadlink 形式の赤外線パケットになっている。先頭が 0x26 で始まり、以降がマイクロ秒単位のパルス長の配列だ。デコードしてフレームに分割し、バイト列に戻す。
結果はこうなった。
| バイト数 | 該当ファイル数 |
|---|---|
| 35バイト(280bit) | 6個 |
| 27バイト(216bit) | 3個 |
| 22バイト(176bit) | 1個 |
| 20バイト(160bit) | 2個 |
| 19バイト(152bit) | 3個 |
| その他 | 6個 |
一方うちのリモコンは 39バイト。1つも一致しない。
念のため、21ファイルに含まれる全 9,150 コマンドを総当たりでスキャンして、39バイト・2セクション構造のものが1つでもないか探した。ゼロだった。
既存 JSON を順番に試していたら、21回全部外れた末に結局同じ解析をすることになっていた。調べておいてよかった。
キャプチャして中身を見る
うちの構成は Zigbee の赤外線ブラスター(Tuya ZS06 系)を Zigbee2MQTT 経由で使っている。このデバイスには学習機能があり、リモコンから受けた信号を Base64 で吐いてくれる。
リモコンの「停止」ボタンを撃つと、こんな文字列が返ってきた。
JgCLAhALEAoQChALDgwPAAMKbjUNKA0NDQ0NDQ0oDQ0NDQ0NDQ0NKA0NDSgOKA0NDSgNKA0oDSgNKA0NDQ0N...
先頭 JgC を Base64 デコードすると 26 00 8B 02 になる。0x26 は Broadlink の赤外線パケットの目印で、その後の 2 バイトがリトルエンディアンでペイロード長(0x028B = 651バイト)。
パルス列に戻すとこうなった。
[525, 361, 525, 328, 525, 328, 525, 361, 460, 394, 493, 25547] ← リーダー
[3612, 1740] ← ヘッダー
[427, 1313] [427, 427] [427, 427] ... ← ビット列
ヘッダーが 3.6ms / 1.7ms、ビットマークが 427µs、スペースが 1313µs なら 1、427µs なら 0。典型的な PWM 符号化で、ダイキン系の定数(kDaikinHdrMark = 3650 など)とよく一致する。
大きなギャップでフレームを区切り、LSB first でバイトに詰め直すと:
frame1 (20 bytes): 11 DA 27 00 02 00 00 00 00 00 00 80 10 00 10 00 00 00 00 B4
frame2 (19 bytes): 11 DA 27 00 00 08 DE 80 A0 00 00 06 60 00 00 C3 00 00 41
合計 39 バイト。両フレームとも 11 DA 27 始まりで、これはダイキンのシグネチャだ。
そして末尾のバイトがチェックサムだった。
frame1: 0x11+0xDA+0x27+0x02+0x80+0x10+0x10 = 0x1B4 → 下位8bit = B4 ✓
frame2: 0x11+0xDA+0x27+0x08+0xDE+0x80+0xA0+0x06+0x60+0xC3 = 0x441 → 41 ✓
両方一致。デコードが正しいことの裏付けになる。以降、全キャプチャでこれを検算に使った。
差分でフィールドを1つずつ潰す
ここからは地道な作業になる。設定を1つだけ変えて2回キャプチャし、変化したバイトを見る。それを繰り返す。
温度
冷房26℃と冷房27℃を撃つ。
26度: 11 DA 27 00 00 39 34 00 AF 00 00 06 60 00 00 C3 00 00 57
27度: 11 DA 27 00 00 39 36 00 AF 00 00 06 60 00 00 C3 00 00 59
^^ ^^
動いたのは 2 バイトだけ。0x34(52)→ 0x36(54)。52 = 26 × 2、54 = 27 × 2。温度を 2 倍した値が入っている。最後の 1 バイトはチェックサムが連動しただけ。
1ビットも余計に動いていないのが気持ちいい。
モード
冷房と暖房を比較する。
冷房: ... 39 34 ... 0x39 = 0011 1001
暖房: ... 49 34 ... 0x49 = 0100 1001
bit0 が電源、bits4-6 がモード。冷房が 011 = 3、暖房が 100 = 4。IRremoteESP8266 の kDaikinCool = 0b011、kDaikinHeat = 0b100 と一致する。ドライは 2、送風は 6、自動は 0 だった。
bit3 が常に立っているが、これは固定値のようだ。
風量
風量を最小・最大・しずかに変えて撃つ。
| 設定 | バイト |
|---|---|
| 風量1(最小) | 0x3F |
| 風量5(最大) | 0x7F |
| 自動 | 0xAF |
| しずか | 0xBF |
上位ニブルが風量で、最小が 3、最大が 7。間は 4/5/6 の連番と推測できる(kDaikinFanMin = 1 から始まる番号に +2 したもの)。自動が 0xA、しずかが 0xB で、これもライブラリの定数と一致した。
下位ニブルの F はスイングだった。風向を固定位置にすると 0 に変わる。
風向
一番上と一番下で撃つ。
一番上: s1 byte12 = 0x10
一番下: s1 byte12 = 0x50
スイング: s1 byte12 = 0x00
上位ニブルが位置番号。5段階なので 0x10〜0x50。
ここで以前の解釈を1つ訂正することになった。 暖房のキャプチャを取ったとき、byte12 が 0x20 になっていて「モードに連動するバイトかもしれない」とメモしていた。実際は、そのとき風向が位置2に設定されていただけだった。モードとは無関係。
差分法の落とし穴で、「1つだけ変えたつもりが2つ変わっていた」というのはよくある。チェックサムが通っていても検出できないので、後から別の実験で辻褄が合わなくなって初めて気付く。
快眠タイマー
1時間と2時間で撃つ。
1時間: byte30 = 0x3C = 60
2時間: byte30 = 0x78 = 120
分単位でそのまま入っている。念のため10時間も撃った。
10時間: byte30 = 0x58, byte31 = 0x02
600分 = 0x258。下位8bitが byte30、上位4bitが byte31 の下位ニブル。12bit のリトルエンディアンだった。
タイマー無効時は byte30 = 0x00, byte31 = 0x06 で、12bit 値としては 1536。これは IRremoteESP8266 の kDaikinUnusedTime = 0x600 と完全に一致する。マジックナンバーに名前が付いていたわけだ。
フラグ類
ストリーマ、内部クリーンを個別にトグルすると、いずれも s1 の byte14 の 1 ビットだけが動いた。
| bit | 機能 |
|---|---|
bit4 (0x10) |
ストリーマ |
bit5 (0x20) |
快眠 |
bit6 (0x40) |
内部クリーン |
きれいなビットフィールドになっている。
「風ナイス」(人に直接風を当てない機能)だけは byte36 の bit0 にあり、しかもONにするとスイングが自動でOFFになる。エアコン側が風向を自律制御するので、通常のスイング設定と排他になるらしい。生成時もこの挙動を再現する必要がある。
自動モードだけ様子がおかしい
ここで気付いたことがある。うちのリモコンの「自動」モードには、絶対温度の設定がない。代わりに −5 から +5 のオフセットを指定する。
撃ってみる。
| 設定 | byte26 |
|---|---|
| ±0 | 0xC0 |
| +1 | 0xC2 |
| −1 | 0xDE |
+1 で +2 されているので、温度と同じ 2 倍スケールなのは分かる。問題は −1 の 0xDE だ。単純な二の補数なら 0xBE になるはずだが、そうなっていない。
下位 5 ビットだけ見る。
0xC0 → 00000 = 0
0xC2 → 00010 = 2
0xDE → 11110 = 30
30 は 5bit の二の補数で −2。つまり オフセットを 2 倍して 5bit 二の補数で格納していた。式にすると 0xC0 | ((offset * 2) & 0x1F)。
そしてこれが分かった瞬間、最初のキャプチャの謎も解けた。
一番最初に撃った「停止」コマンドの byte26 が 0xDE で、当時は「停止時の特殊値」として保留していた。実際はこれ、自動モードのオフセット −1 だった。停止ボタンを押したとき、リモコンがたまたま自動モード・オフセット −1 だっただけ。byte25 が 0x08(モードニブル 0 = 自動、電源 0)なのも辻褄が合う。
例外だと思っていたものが、後から一般則で説明できたときの感覚は悪くない。
生成して実機で検証する
仕様が固まったので、設定からバイト列を組み立てるコードを書く。
s2[5] = ((mode & 0x0F) << 4) | 0x08 | (1 if power else 0)
s2[6] = (temp * 2) & 0xFF
s2[8] = ((fan & 0x0F) << 4) | swing
s2[18] = sum(s2[:18]) & 0xFF
そして生成したバイト列が、実際のキャプチャと一致するかを全件で確かめる。
$ python3 tests/test_against_captures.py
PASS auto_minus1
PASS auto_plus1
...
PASS vane_top
24/24 captures reproduced exactly
24件すべてでバイト単位の完全一致。ここまで来れば、仕様の理解が正しいと言っていい。
あとはこれを Broadlink 形式にエンコードして送るだけ。実機に投げたら、ちゃんとスイングが始まった。
DAIKIN2 か DAIKIN312 か
39バイト・セクション 20+19 という構造から、IRremoteESP8266 の DAIKIN2 に該当すると当たりを付けていた。公開するにあたって裏を取ろうとソースを落としたら、危ないところだった。
const uint16_t kDaikin2StateLength = 39; // Section1=20, Section2=19
const uint16_t kDaikin312StateLength = 39; // Section1=20, Section2=19
DAIKIN312 も 39 バイトで、セクション長まで同じだった。バイト構造では区別できない。
タイミングで見分けようとしたが、これも決着しない。
| 実測 | DAIKIN2 | DAIKIN312 | |
|---|---|---|---|
| セクション間ギャップ | 35,924 | 35,204 | 35,512 |
| ヘッダー space | 1,740 | 1,728 | 1,688 |
セクション間ギャップは DAIKIN312 寄り、ヘッダー space は DAIKIN2 寄り。そもそも Broadlink 形式の学習器は 269/8192 ms 刻みで量子化するので、分解能が約 33µs ある。両者の差より測定誤差の方が大きい。
決め手は構造体のコメントにあった。
// Byte 37: Always 0x08
uint8_t :8;
DAIKIN312 は byte37 が常に 0x08 でなければならない。うちの実測は全 24 キャプチャで 0x00 だった。DAIKIN312 固有の「音声アナウンス種別」バイト(raw[9])も全て 0x00。
DAIKIN2 で確定。 タイミングの近さだけで判断していたら間違えていた可能性がある。
既知のマップに載っていないもの
ライブラリの Daikin2Protocol 構造体と、実測で導いたフィールドを突き合わせた。
一致した部分:
| フィールド | 位置 | 結果 |
|---|---|---|
| チェックサム | byte19, byte38 | ✓ |
| 電源 | byte25 bit0 | ✓ |
| モード | byte25 bits4-6 | ✓ 定数も一致 |
| 温度 | byte26 | ✓ |
| 風量 | byte28 上位ニブル | ✓ 定数も一致 |
| タイマー | byte30-31 の12bit | ✓ 無効値 0x600 も一致 |
特に温度は、ライブラリでは Temp:6 というビット1から始まる6ビットフィールドとして定義されていた。「2倍する」という意図的なスケーリングではなく、ビット位置がずれているだけだったわけだ。実測から「×2」と推論したものの正体が分かって腑に落ちた。
一方、一致しなかった部分がある。
| 機能 | 実測の位置 | ライブラリの扱い |
|---|---|---|
| 風向固定位置 | byte12 上位ニブル | 未使用(SwingV は byte18) |
| ストリーマ | byte14 bit4 | 未使用 |
| 快眠フラグ | byte14 bit5 | 未使用 |
| 内部クリーン | byte14 bit6 | Clean は byte8 |
| スイングON/OFF | byte28 下位ニブル | 未使用 |
| 風ナイス | byte36 bit0 | 未使用 |
| 自動モードのオフセット | byte26 | 概念が存在しない |
ライブラリが SwingV を置いている byte18 は、うちのキャプチャでは全件 0x00 だった。つまりこのリモコンは本当にそこを使っていない。
ARC478A15 は DAIKIN2 ファミリでありながら、補助機能のビット配置が既知のマップと異なる変種ということになる。コアのフィールドは完全に一致しているので、別プロトコルというより「未文書の派生」と見るのが妥当だろう。
まとめ
- ダイキンの赤外線プロトコルは複数あり、機種が違えば互換性がない
- SmartIR のダイキン用 JSON 21ファイル・9,150コマンドに、この機種の構造は存在しなかった
- 24回のキャプチャと差分解析で全フィールドを特定し、2,730個のコードを生成した
- 生成結果は全キャプチャとバイト単位で一致し、実機でも動作した
- 既知のライブラリが未使用としている領域に、この機種固有の機能が入っていた
手で 2,730 回ボタンを押す代わりに 24 回撃って解析した、という話でもある。前者を選んでいたら、風向や快眠タイマーは諦めていただろうし、そもそも途中で心が折れていたと思う。
コードとキャプチャデータは全部公開してある。同じリモコンを使っている人はそのまま使えるはずだし、別の機種でも tools/inspect_capture.py で同じ手順を踏めば解析できる。