C
JavaScript
Node.js
RaspberryPi

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

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

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

リモコン

Remote.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か家製協のどちらかなのですが、
赤外線リモコンの通信フォーマット
ここのサイトと照らし合わせてみると、
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というざっくりした条件で判別します。

プログラム

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

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

送信

解析ができたので、あとは解析結果に従って信号を生成するプログラムを書くだけです。信号を生成するプログラムは、後々Web経由で操作しやすいように、Node.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を利用して赤外線リモコンの信号をスキャン・送信
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

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

参考サイト

Rasberry Pi 3でリモコン信号を解析する (10/1リモコン信号解析結果更新)
赤外線リモコンの通信フォーマット