Raspberry Piで三菱のエアコンのリモコンをスキャン・解析・送信してみる

[Raspberry PiのGPIOを利用して赤外線リモコンの信号をスキャン・送信](https://qiita.com/Hiroki_Kawakami/items/3f7d332797d188dc9022)
前回こちらの記事を書きました。テレビやシーリングライトのリモコンなら単純で、同じボタンを押したら必ず同じ信号が出るので楽なのですが、エアコンのリモコンは少し特殊でそうはいきません。
エアコンの場合、リモコンが現在のエアコンの状態を把握しており、その状態によってボタンを押した時の動作が変わります。
とはいえ、エアコンをOffにするときの信号・28度で冷房にするときの信号・20度で暖房にするときの信号という形でスキャンしておけば冷房・暖房のON/OFFはできるようになります。
ですが、せっかくなのでリモコンを解析して設定温度や風向なども自由に調節できるようにしたいと思います。

前回の記事にあるscan,sendプログラムを利用するので、読んでない方はそちらを先に読んでください。

# リモコン
![Remote.png](https://qiita-image-store.s3.amazonaws.com/0/242402/0fc0b505-2746-a55c-a589-19fd04e4f1ad.png)

解析に使ったリモコンはこれです。
型名はNH122 205AL
霧ヶ峰のエアコンについてるやつ。

# スキャン

とりあえずまずはエアコンのリモコンをスキャンしてみます。

```
pi@raspberrypi:~/IR $ ./scan 20 > heat20
GPIO Pin Number : 20
Scanning begin
Scanning end
Output begin
Output end
pi@raspberrypi:~/IR $ cat heat20
3499	1681
460 	1257
462 	1256
460 	396
463 	396
462 	400
461 	1255
461 	399
459 	400
461 	1256
461 	1256
460 	400
（中略）
459 	1260
459 	400
458 	400
458 	13283
3470	1680
461 	1256
461 	1258
460 	399
460 	399
461 	399
459 	1256
461 	399
460 	399
459 	1259
460 	1255
461 	400
（中略）
458 	1264
457 	399
459 	399
458 	
```

これを眺めてどのフォマットが利用されているか判断します。
まず、SONY製ではないのでNECか家製協のどちらかなのですが、
[赤外線リモコンの通信フォーマット](http://elm-chan.org/docs/ir_format.html)
ここのサイトと照らし合わせてみると、
1行目、つまりLeaderの点灯時間3499が2行目以降のDataを送っているときの点灯時間460のおおよそ8倍となっているので家製協フォーマットであることがわかります。
また、途中にも点灯時間が3470と長くなってる場所があるので、このリモコンでは2フレームの信号を送っているのでしょう。
でもリピートは出していないようですね。
でも、これだけ眺めていてもさっぱり分からないので、こいつを16進数に変換するプログラムを書きましょう。

# 解析
## 16進数に変換
まずはスキャンしてできたファイルを16進数に変換します。
家製協フォーマットではLeaderが送信された後に1バイト目のLSBからMSBまで8ビット順番に送られます。そのあとに2バイト目、3バイト目と順番にきて、最後8ms以上の消灯でフレームが終わります。

とりあえずTrailerとRepeatは無視してLeaderとDataのみに注目します。
リモコンのフォーマットには変調単位Tがあり、点灯時間と消灯時間がTの何倍かによって送られた情報を判別します。
Leaderでは点灯時間が8T、消灯時間が4T
0が送信されたときは点灯時間がT、消灯時間がT
1が送信されたときは点灯時間がT、消灯時間が3T
になります。
なので、Tが350〜500であることを踏まえて、とりあえず点灯時間が長かったらLeader、点灯時間と消灯時間が同じくらいだったら0、消灯時間が点灯時間よりも大きかったら1というざっくりした条件で判別します。

### プログラム

```c:aeha.c
//
//  aeha.c
//  Copyright © 2018 Hiroki Kawakami. All rights reserved.
//
#include<stdio.h>
#include<stdlib.h>

int main() {
	
	int count, offset = -1;
	unsigned int on, off;
	unsigned char byte = 0;
			
	while(1) {
		count = scanf("%u%u", &on, &off);
		if (count < 2 || off == 0) {
			break;
		}
		
		if (on > 1500) {
			if (offset >= 0) printf("\n");
			offset = 0;
			byte = 0;
		} else {
			byte |= (off > on * 2) << offset++;
           		 
			if ((offset & 7) == 0) {
				printf("%02X ", byte);
				offset = 0;
				byte = 0;
			}
		}
	}
	printf("\n");

	return 0;
}
```

### コンパイル

```
$ gcc -o aeha aeha.c
```

### 実行
スキャンした時にできたheat20ファイルを変換

```
pi@raspberrypi:~/IR $ cat heat20 | ./aeha
23 CB 26 01 00 20 48 04 30 6A 00 00 00 00 10 00 00 2B 
23 CB 26 01 00 20 48 04 30 6A 00 00 00 00 10 00 00 2B 
```

これでリモコンが送信している信号を16進数で見ることができるようになりました。
このリモコンは同じフレームを2回送信していることがわかりますね。

## 解析

信号を16進数で見ることができたので、あとはリモコンの信号をいくつかモードを変えてスキャンして、それを照らし合わせることで解析していくわけですが、いちいちスキャンして16進数に変換して比較してって面倒ですよね。
でも大丈夫です。何もファイルの入出力に標準入出力を使っていたのは楽するためだけじゃありません。
これ、スキャンプログラムと16進数変換プログラムをパイプで直接繋ぐことができるのです。

### コマンド

```
$ while true; do ./scan 20 2>/dev/null | ./aeha; done
```

```./scan 20 | ./aeha```でscanプログラムと16進数変換プログラムをパイプで繋げられるのですが、スキャンプログラムのstderr出力が邪魔なので```2>/dev/null```で捨てています。そして、これだけでは一回スキャンしたらプログラムが終了してしまうので、while true無限ループでプログラムの起動し直しを行なっています。
これをすることで、連続してリモコンの信号をスキャンして16進数の値で見ることができます。
終わったらCtrl+Cで抜ければ大丈夫です。

### 実行

あとは、上に書いてあるコマンドを実行してからセンサーに向けて次々とリモコンの信号を送っていき、表示された値を見て比べるだけです。

```
pi@raspberrypi:~/IR $ while true; do ./scan 20 2>/dev/null | ./aeha; done
23 CB 26 01 00 20 48 04 30 40 00 00 00 00 10 00 00 01 
23 CB 26 01 00 20 48 04 30 40 00 00 00 00 10 00 00 01 
23 CB 26 01 00 20 48 03 30 40 00 00 00 00 10 00 00 00 
23 CB 26 01 00 20 48 03 30 40 00 00 00 00 10 00 00 00 
23 CB 26 01 00 20 48 02 30 40 00 00 00 00 10 00 00 
23 CB 26 01 00 20 48 02 30 40 00 00 00 00 10 00 00 FF 
23 CB 26 01 00 20 48 01 30 40 00 00 00 00 10 00 00 FE 
23 CB 26 01 00 20 48 01 30 40 00 00 00 00 10 00 00 FE 
23 CB 26 01 00 20 48 00 30 80 00 00 00 00 10 00 00 3D 
23 CB 26 01 00 20 48 00 30 80 00 00 00 00 10 00 00 3D 
```

稀に正常に変換できないことがありますが、このリモコンは同じ信号を2回送っているので解析に支障はありません。
これはセンサーに向けて設定温度を下げるボタンを押していき、順番に暖房20度から暖房16度までの5つの信号を入れた結果です。

最後の1バイトは誤り検出に使われるものだと思うので無視するとして、7バイト目（一番左が0バイト目）の値が変化していることが分かります。

```
pi@raspberrypi:~/IR $ while true; do ./scan 20 2>/dev/null | ./aeha; done
23 CB 26 01 00 20 58 0C 36 40 00 00 00 00 10 00 00 1F 
23 CB 26 01 00 20 58 0C 36 40 00 00 00 00 10 00 00 1F 
23 CB 26 01 00 20 58 0D 36 40 00 00 00 00 10 00 00 20 
23 CB 26 01 00 20 58 0D 36 40 00 00 00 00 10 00 00 20 
23 CB 26 01 00 20 58 0E 36 40 00 00 00 00 10 00 00 21 
23 CB 26 01 00 20 58 0E 36 40 00 00 00 00 10 00 00 21 
23 CB 26 01 00 20 58 0F 36 80 00 00 00 00 10 00 00 62 
23 CB 26 01 00 20 58 0F 36 80 00 00 00 00 10 00 00 62 
```

また、これは冷房28度から冷房31度まで設定温度を上げるボタンを押していき順番に4つの信号を入れた結果であり、やはり7バイト目の値が変化していることが分かります。

また、この7バイト目の値は計算すると設定温度から-16した値になっていることが分かります。
よって、設定温度は7バイト目に格納されていることが分かりました。

あとは運転モードや風速、風向なども同じように1つの条件だけを変えた信号を順番に入れていき値の変化を見ていくだけです。

### 最後の1バイト

参考サイトにNationalのエアコンのリモコンを解析したものがありました。そのリモコンは家製協フォーマットで、最後の1バイトが全てのバイトを加算したのの下位8ビットとなっていました。もしかしたらと思って今解析しているリモコンで確認してみると、参考サイトのと同じく加算したのの下位8ビットでした。

## 解析結果

![解析.png](https://qiita-image-store.s3.amazonaws.com/0/242402/8227486d-9575-6147-3c5d-33c8e94b4954.png)

タイマーをセットした時の信号は解析していません。それ、プログラムで制御した方が楽だし柔軟に対応できるので。
風エリアは、0のときは風左右で指定された値になります。ここで指定すると、風左右の値を使わずに、風エリアで指定された向きになります。

# 送信

解析ができたので、あとは解析結果に従って信号を生成するプログラムを書くだけです。信号を生成するプログラムは、後々Web経由で操作しやすいように、Node.jsで作りました。

## プログラム

```js:mbac.js
//
//  mbac.js
//  Copyright © 2018 Hiroki Kawakami. All rights reserved.
//

var bytes = [0x23, 0xcb, 0x26, 0x01, 0x00, 0x00, 0x58, 12, 0x32, 0x40, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0];

var aeha = function(T, bytes, repeats, interval) {
    var result = "";
    var i = 0;
    var length = bytes.length;
    while (true) {
        result += T * 8 + " " + T * 4 + "\n"; // Leader
        for (var j = 0; j < length; j++) {
            for (var k = 0; k < 8; k++) {
                if ((bytes[j] & (1 << k)) != 0) { // 1
                    result += T + " " + T * 3 + "\n";
                } else { // 0
                    result += T + " " + T + "\n";
                }
            }
        }
        if (++i >= repeats) {
            result += T;
            break;
        } else {
            result += T + " " + interval + "\n"; // Trailer
        }
    }
    return result;
}

var UpdateCheckByte = function() {
    var sum = 0;
    for (var i = 0; i < bytes.length - 1; i++) {
        sum += bytes[i];
    }
    bytes[bytes.length - 1] = sum & 0xff;
}

var SetPower = function(power) {
    if (power.toLowerCase !== undefined) {
        power = power.toLowerCase()
    }
    if (!power || power == "off" || power == "false") {
        bytes[5] = 0x00;
        savedState.power = false;
    } else {
        bytes[5] = 0x20;
        savedState.power = true;
    }
}

var SetMode = function(mode) { // モードをセット（cool: 冷房, heat: 暖房, dry: 除湿, wind: 送風）
    mode = mode.toLowerCase();
    var byte = {"cool": 0x58, "heat": 0x48, "dry": 0x50, "wind": 0x38}[mode];
    if (byte === undefined) {
        console.log("Mode %s is not defined!", mode);
        return false;
    }
    SetPower(true);
    bytes[6] = byte;
    savedState.mode = mode;
    if (mode == "cool") {
        SetTemperature(savedState.coolTemperature);
        bytes[8] = (bytes[8] & 0xf0) | 0x6;
    } else if (mode == "heat") {
        SetTemperature(savedState.heatTemperature);
    } else if (mode == "dry") {
        SetDryIntensity(savedState.dryIntensity);
    }
    return true;
}

var SetTemperature = function(temperature) { // 設定温度をセット（16~31）
    if (temperature < 16 || temperature > 31) {
        console.log("Temperature %d is out of range (16 ~ 31).", temperature);
        return false;
    }
    bytes[7] = temperature - 16;
    if (savedState.mode == "cool") {
        savedState.coolTemperature = temperature;
    } else if (savedState.mode == "heat") {
        savedState.heatTemperature = temperature;
    }
    return true;
}

var SetDryIntensity = function(intensity) { // 除湿強度（high: 強, normal: 標準, low: 弱）
    intensity = intensity.toLowerCase();
    var byte = {"high": 0x0, "normal": 0x2, "low": 0x4}[intensity];
    if (byte === undefined) {
        console.log("Dry intensity %s is not defined!", intensity);
        return false;
    }
    bytes[8] = (bytes[8] & 0xf0) | byte;
    savedState.dryIntensity = intensity;
    return true;
}

var SetWindHorizontal = function(horizontal) { // 風向左右（1: 最左 ~ 3: 中央 ~ 5: 最右, 6: 回転）
    if (horizontal <= 0 || horizontal > 6) {
        console.log("Horizontal wind direction %d is out of range (1 ~ 6).", horizontal);
        return false;
    }
    if (horizontal == 6) {
        horizontal = 0xc;
    }
    bytes[8] = (bytes[8] & 0xf) | (horizontal << 4);
    savedState.horizontal = horizontal;
    return true;
}

var SetWindVertical = function(vertical) { // 風向上下（0: 自動, 1: 最上 ~ 5: 最下, 6: 回転）
    if (vertical < 0 || vertical > 6) {
        console.log("Vertical wind direction %d is out of range (0 ~ 6).", vertical);
        return false;
    }
    if (vertical == 6) {
        vertical = 7;
    }
    bytes[9] = (bytes[8] & 0b11000111) | (vertical << 3);
    savedState.vertical = vertical;
    return true;
}

var SetWindSpeed = function(speed) { // 風速（0: 自動, 1: 弱, 2: 中, 3: 強, 4: パワフル）
    if (speed < 0 || speed > 4) {
        console.log("Wind speed %d is out of range (0 ~ 4).", speed);
        return false;
    }
    var powerful = 0x00;
    if (speed == 4) {
        speed = 3;
        powerful = 0x10;
    }
    bytes[9] = (bytes[9] & 0b11111000) | speed;
    bytes[15] = powerful;
    savedState.speed = speed;
    return true;
}

var SetWindArea = function(area) { // 風エリア（none: 風左右の値を利用, whole: 全体, left: 左半分, right: 右半分）
    var byte = {"none": 0x00, "whole": 0x8, "left": 0x40, "right": 0xc0}[area.toLowerCase()];
    if (byte === undefined) {
        console.log("Wind area %s is not defined!", area);
        return false;
    }
    bytes[13] = byte;
    savedState.area = area;
    return true;
}

var fs = require("fs");
var savedState = {
    "power": false, 
    "mode": "cool", 
    "coolTemperature": 28, 
    "heatTemperature": 20, 
    "dryIntensity": "normal", 
    "horizontal": 5, 
    "vertical": 0,
    "speed": 0,
    "area": "none"
};
try {
    savedState = JSON.parse(fs.readFileSync("mbac.sav", "utf8"));
} catch(error) {}

try {
    if (savedState.mode !== undefined) {
        SetMode(savedState.mode);
    }
    if (savedState.power !== undefined) {
        SetPower(savedState.power);
    }
    if (savedState.horizontal !== undefined) {
        SetWindHorizontal(savedState.horizontal);
    }
    if (savedState.vertical !== undefined) {
        SetWindVertical(savedState.vertical);
    }
    if (savedState.speed !== undefined) {
        SetWindSpeed(savedState.speed);
    }
    if (savedState.area !== undefined) {
        SetWindArea(savedState.area);
    }
} catch(error) {console.error(error)}

var SaveState = function() {
    fs.writeFile('mbac.sav', JSON.stringify(savedState));
}

if (require.main === module) {
    var i = 2;
    while (i < process.argv.length) {
        var key = process.argv[i];
        if (key == "-p" || key == "--power") {
            SetPower(process.argv[i + 1]);
            i += 2;
        } else if (key == "-m" || key == "--mode") {
            if (!SetMode(process.argv[i + 1])) {
                console.log("Invalid value of mode option \"%s\"", process.argv[i + 1]);
                return;
            }
            i += 2;
        } else if (key == "-t" || key == "--temperature") {
            if (!SetTemperature(process.argv[i + 1])) {
                console.log("Invalid value of temperature option \"%s\"", process.argv[i + 1]);
                return;
            }
            i += 2;
        } else if (key == "-d" || key == "--dry_intensity") {
            if (!SetDryIntensity(process.argv[i + 1])) {
                console.log("Invalid value of dry intensity option \"%s\"", process.argv[i + 1]);
                return;
            }
            i += 2;
        } else if (key == "-h" || key == "--horizontal") {
            if (!SetWindHorizontal(process.argv[i + 1])) {
                console.log("Invalid value of wind horizontal option \"%s\"", process.argv[i + 1]);
                return;
            }
            i += 2;
        } else if (key == "-v" || key == "--vertical") {
            if (!SetWindVertical(process.argv[i + 1])) {
                console.log("Invalid value of wind vertical option \"%s\"", process.argv[i + 1]);
                return;
            }
            i += 2;
        } else if (key == "-s" || key == "--speed") {
            if (!SetWindSpeed(process.argv[i + 1])) {
                console.log("Invalid value of wind speed option \"%s\"", process.argv[i + 1]);
                return;
            }
            i += 2;
        } else if (key == "-a" || key == "--area") {
            if (!SetWindArea(process.argv[i + 1])) {
                console.log("Invalid value of wind area option \"%s\"", process.argv[i + 1]);
                return;
            }
            i += 2;
        } else {
            console.log("Invalid option key \"%s\"", key);
            return;
        }
    }
    UpdateCheckByte();
    SaveState();
    var signal = aeha(430, bytes, 2, 13300);
    console.log(signal);
}
```

まず、bytesは生成した信号を格納する配列です。
aeha関数は、バイト配列から家製協フォーマットに従って、LEDの点灯・消灯時間を出力する関数です。
UpdateCheckByte関数は、信号の一番最後にあるチェック用の値を計算してセットする関数です。
その後に、エアコンの各種状態をセットする関数があります。
```var fs = require("fs");```からは、前回のエアコンの状態を復元させ、現状態を保存するプログラムとなります。これによって、毎回エアコンの状態全てを指定せずに、変更したい部分だけ指定すれば動くようになります。
```if (require.main === module)```の中身は、コマンドライン引数の評価および実行を行なっています。現状、コマンドラインからの操作を常用するつもりはないので、ここの実装は適当です。

## 実行

[Raspberry PiのGPIOを利用して赤外線リモコンの信号をスキャン・送信](https://qiita.com/Hiroki_Kawakami/items/3f7d332797d188dc9022)
sendプログラムは上記の記事にあるものをそのまま使用します。具体的な使い方はそっちを見てください。

冷房30度
```node mbac.js -m cool -t 30 | sudo ./send 18```

暖房20度
```node mbac.js -m heat -t 20 | sudo ./send 18```

電源オフ
```node mbac.js -p off | sudo ./send 18```

そのほかにも以下のオプションを使って操作できます

![オプション.png](https://qiita-image-store.s3.amazonaws.com/0/242402/57996011-656f-7462-6d74-9a3dd7a57907.png)

プログラム自体は単純だけど、ろくにテストしてないのでバグがあるかも。

# 参考サイト
[Rasberry Pi 3でリモコン信号を解析する　(10/1リモコン信号解析結果更新）](http://kagemomiji.hateblo.jp/entry/2016/09/03/102809)
[赤外線リモコンの通信フォーマット](http://elm-chan.org/docs/ir_format.html)