Raspberry Piで三菱のエアコンをブラウザから操作する

# 前回の記事

[Raspberry PiのGPIOを利用して赤外線リモコンの信号をスキャン・送信](https://qiita.com/Hiroki_Kawakami/items/3f7d332797d188dc9022)
[Raspberry Piで三菱のエアコンのリモコンをスキャン・解析・送信してみる](https://qiita.com/Hiroki_Kawakami/items/37cdb412a4e511a58103)

これらの記事でやった内容を使うので、こちらから読むことをお勧めします

# エアコンを遠隔操作したい

家に帰る直前に外出先からエアコンをつけると、帰ったときには部屋がちょうどいい温度になってるっていうのをしたい。
三菱エアコン用の無線LANアダプター？
そんなの知らないね。

# socket.io

Node.jsでsocket.ioを使います。これを使えば簡単にインタラクティブなWebアプリが作れます。
socket.ioの詳しい使い方はここでは書かないので適宜調べてください。

# ソースコード
GitHubにソースコードあるので、全部はここには載せません。
https://github.com/Hiroki-Kawakami/RaspberryPi-IR

## サーバー(Raspberry Pi)側

前回記事でエアコン信号の生成にNode.jsを使ってたけど、今見返すとあまりに醜く汚く呆れる程下手なコードだったので、結局共通化できそうな部分もほとんど書き直してます。

```Javascript:mbac_web.js
var fs = require("fs");
var exec = require('child_process').exec;

function aeha(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;
}

function bytesFromState(state) {
    var bytes = [0x23, 0xcb, 0x26, 0x01, 0x00, 0x00, 0x58, 12, 0x02, 0x40, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0];
    bytes[5] = (state.power) ? 0x20 : 0; // power
    bytes[6] = {"cool": 0x58, "heat": 0x48, "dry": 0x50, "wind": 0x38}[state.mode]; // mode

    // temperature, intensity
    if (state.mode == "cool") {
        bytes[7] = state.coolTemperature - 16;
        bytes[8] = 0x6;
    } else if (state.mode == "heat") {
        bytes[7] = state.heatTemperature - 16;
    } else if (state.mode == "dry") {
        bytes[8] = {"high": 0x0, "normal": 0x2, "low": 0x4}[state.dryIntensity];
    }

    // wind horizontal
    if (state.horizontal >= 6) {
        bytes[8] += 0xc0;
    } else {
        bytes[8] += state.horizontal << 4;
    }

    // wind vertical
    if (state.vertical >= 6) {
        bytes[9] += 0x38;
    } else {
        bytes[9] += state.vertical << 3;
    }

    // wind speed
    if (state.speed >= 4) {
        bytes[9] += 3;
        bytes[15] = 0x10;
    } else {
        bytes[9] += state.speed;
    }
    
    // wind area
    bytes[13] = {"none": 0x00, "whole": 0x8, "left": 0x40, "right": 0xc0}[state.area];

    // update check byte
    var sum = 0;
    for (var i = 0; i < 17; i++) {
        sum += bytes[i];
    }
    bytes[17] = sum & 0xff;

    return bytes;
}

var currentState = {
    "power": false, 
    "mode": "cool", // 運転モード（cool: 冷房, heat: 暖房, dry: 除湿, wind: 送風）
    "coolTemperature": 28, // 冷房時の設定温度（16~31）
    "heatTemperature": 20, // 暖房時の設定温度（16~31）
    "dryIntensity": "normal", // 除湿強度（high: 強, normal: 標準, low: 弱）
    "horizontal": 5, // 風向左右（1: 最左 ~ 3: 中央 ~ 5: 最右, 6: 回転）
    "vertical": 0, // 風向上下（0: 自動, 1: 最上 ~ 5: 最下, 6: 回転）
    "speed": 0, // 風速（0: 自動, 1: 弱, 2: 中, 3: 強, 4: パワフル）
    "area": "none" // 風エリア（none: 風左右の値を利用, whole: 全体, left: 左半分, right: 右半分）
};

try {
    currentState = JSON.parse(fs.readFileSync("mbac.sav", "utf8"));
} catch(error) {}

function saveCurrentState() {
    fs.writeFileSync('mbac.sav', JSON.stringify(currentState));
}

// Setup Server
var server = require("http").createServer(function(req, res) {
    var url = req.url;
    if (url == "/") {
        url = "/index.html";
    }
    var textTypes = {"html": "text/html", "js": "text/javascript", "css": "text/css", "svg": "image/svg+xml"};
    var binaryTypes = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "ico": "image/x-icon"};
    var dotIndex = url.lastIndexOf(".");
    var fileExt = url.slice(dotIndex - url.length + 1);
    try {
        if (binaryTypes[fileExt]) {
            var output = fs.readFileSync("static" + url, "binary");
            res.writeHead(200, {"Content-Type": binaryTypes[fileExt]});
            res.write(output, "binary");
        } else if (textTypes[fileExt]) {
            var output = fs.readFileSync("static" + url, "utf-8");
            res.writeHead(200, {"Content-Type": textTypes[fileExt]});
            res.write(output);
        }
        res.end();
    } catch(e) {
        //console.log(e);
    }
}).listen(8080);
var io = require('socket.io').listen(server);

io.sockets.on("connection", function(socket) {

    io.to(socket.id).emit("mbac_update", currentState);

    socket.on("mbac_update", function(state) {
        if (state) {
            currentState = state;
            var bytes = bytesFromState(state);
            var signal = aeha(430, bytes, 2, 13300);
            exec("echo \"" + signal.replace(/\n/g, "\\n") + "\" | sudo ./send 18", function() {});
            io.sockets.emit("mbac_update", currentState);
            saveCurrentState();
            console.log(state);
        } else {
            io.to(socket.id).emit("mbac_update", currentState);
        }
    });
});
```

最初の2行はモジュールの読み込みです。
bytesFromState関数はエアコンの状態を表すオブジェクトから、エアコンに送るバイト列を生成する関数で、aeha関数はバイト列から家製協フォーマットで赤外線のON,OFF時間を生成する関数です。このaeha関数の出力を前回記事にあるsendプログラムの標準入力に入れることになります。
currentStateはエアコンの現状態を保持しておく変数です。この現状態は変数定義直後にあるtry文の中身とsaveCurrentState関数でmbac.savというファイルにjson形式で保存・復元するようになっているので、プログラムが終了してもプログラムが持つエアコンの状態は保持されるようになっています。

// Setup Server からはサーバーのセットアップになります。
今回、クライアント側で使うhtmlやcss,javascriptはApacheなど別のソフトを使わず、全てNode.jsで配ってしまえという形なので、createServerのコールバック内にurlから適切なContent-Typeとファイルの中身を返すコードを書いています。対応してるファイルはtextTypesとbinaryTypesに定義されてる形式のファイルで、ドキュメントルートはstaticディレクトリになっています。
listen(8080)で8080番ポートを指定しています。

io.sockets.on("connection", にあるコールバックは、クライアントと接続されたときに実行されます。なので、クライアントと接続されたら
```io.to(socket.id).emit("mbac_update", currentState);```
でエアコンの現状態を接続されたクライアントに送ります。
また、
```socket.on("mbac_update", function(state) {```
でクライアントからのイベントを待ち受けします。
クライアントからのイベントでは新しく設定するべきエアコンの状態を受け取るので、その値からエアコンの信号を送るのがコールバックの中身です。
```exec("echo \"" + signal.replace(/\n/g, "\\n") + "\" | sudo ./send 18", function() {});```
このexec関数でsendプログラムに信号を渡しています。当初spawn使って渡すつもりでしたが、なんか面倒になってこうなりました。

## クライアント側

サーバー側で説明した通り、ドキュメントルートがstaticディレクトリなので、html,css,javascriptのファイルは全部ここに入れます。
html,cssについては特に説明することもないと思うので、ここには書きません。GitHubにあるので、必要であればそちらをみてください。

```javascript:index.js
var currentState;
var　socket = io.connect();
socket.on("mbac_update", function(state) {
    console.log(state);
    var allColorClass = ["cool", "dry", "heat", "wind", "stop", "gray", "white"]
    var buttons = {
        cool: document.getElementById("coolButton"),
        dry: document.getElementById("dryButton"),
        heat: document.getElementById("heatButton"),
        wind: document.getElementById("windButton"),
        stop: document.getElementById("stopButton"),
        left: document.getElementById("leftButton"),
        center: document.getElementById("centerButton"),
        right: document.getElementById("rightButton")
    }
    Array.from(document.getElementById("mbacButtons").children).forEach(function(element) {
        allColorClass.forEach(function(classItem) {
            element.classList.remove(classItem);
        })
    });
    if (!state.power) {
        buttons.cool.classList.add("cool");
        buttons.dry.classList.add("dry");
        buttons.heat.classList.add("heat");
        buttons.wind.classList.add("wind");
        buttons.stop.classList.add("white");
        buttons.left.classList.add("white");
        buttons.left.innerText = "";
        buttons.center.classList.add("white");
        buttons.center.innerText = "";
        buttons.right.classList.add("white");
        buttons.right.innerText = "";
    } else if (state.mode == "cool") {
        buttons.cool.classList.add("cool");
        buttons.dry.classList.add("gray");
        buttons.heat.classList.add("gray");
        buttons.wind.classList.add("gray");
        buttons.stop.classList.add("stop");
        buttons.left.classList.add("cool");
        buttons.left.innerText = "▼";
        buttons.center.classList.add("cool");
        buttons.center.innerText = state.coolTemperature + "℃";
        buttons.right.classList.add("cool");
        buttons.right.innerText = "▲";
    } else if (state.mode == "dry") {
        buttons.cool.classList.add("gray");
        buttons.dry.classList.add("dry");
        buttons.heat.classList.add("gray");
        buttons.wind.classList.add("gray");
        buttons.stop.classList.add("stop");
        if (state.dryIntensity == "low") {
            buttons.left.classList.add("dry");
            buttons.center.classList.add("gray");
            buttons.right.classList.add("gray");
        } else if (state.dryIntensity == "high") {
            buttons.left.classList.add("gray");
            buttons.center.classList.add("gray");
            buttons.right.classList.add("dry");
        } else {
            buttons.left.classList.add("gray");
            buttons.center.classList.add("dry");
            buttons.right.classList.add("gray");
        }
        buttons.left.innerText = "弱";
        buttons.center.innerText = "標準";
        buttons.right.innerText = "強";
    } else if (state.mode == "heat") {
        buttons.cool.classList.add("gray");
        buttons.dry.classList.add("gray");
        buttons.heat.classList.add("heat");
        buttons.wind.classList.add("gray");
        buttons.stop.classList.add("stop");
        buttons.left.classList.add("heat");
        buttons.left.innerText = "▼";
        buttons.center.classList.add("heat");
        buttons.center.innerText = state.heatTemperature + "℃";
        buttons.right.classList.add("heat");
        buttons.right.innerText = "▲";
    } else if (state.mode == "wind") {
        buttons.cool.classList.add("gray");
        buttons.dry.classList.add("gray");
        buttons.heat.classList.add("gray");
        buttons.wind.classList.add("wind");
        buttons.stop.classList.add("stop");
        buttons.left.classList.add("white");
        buttons.left.innerText = "";
        buttons.center.classList.add("white");
        buttons.center.innerText = "";
        buttons.right.classList.add("white");
        buttons.right.innerText = "";
    }
    currentState = state;
});
socket.on("connect", function() {});

function coolAction() {
    currentState.power = true;
    currentState.mode = "cool";
    socket.emit("mbac_update", currentState);
}

function dryAction() {
    currentState.power = true;
    currentState.mode = "dry";
    socket.emit("mbac_update", currentState);
}

function heatAction() {
    currentState.power = true;
    currentState.mode = "heat";
    socket.emit("mbac_update", currentState);
}

function windAction() {
    currentState.power = true;
    currentState.mode = "wind";
    socket.emit("mbac_update", currentState);
}

function stopAction() {
    currentState.power = false;
    socket.emit("mbac_update", currentState);
}

function leftAction() {
    if (!currentState.power) {
        return;
    } else if (currentState.mode == "cool") {
        currentState.coolTemperature = Math.max(currentState.coolTemperature - 1, 16);
        socket.emit("mbac_update", currentState);
    } else if (currentState.mode == "dry") {
        currentState.dryIntensity = "low";
        socket.emit("mbac_update", currentState);
    } else if (currentState.mode == "heat") {
        currentState.heatTemperature = Math.max(currentState.heatTemperature - 1, 16);
        socket.emit("mbac_update", currentState);
    }
}

function centerAction() {
    if (!currentState.power) {
        return;
    } else if (currentState.mode == "dry") {
        currentState.dryIntensity = "middle";
        socket.emit("mbac_update", currentState);
    }
}

function rightAction() {
    if (!currentState.power) {
        return;
    } else if (currentState.mode == "cool") {
        currentState.coolTemperature = Math.min(currentState.coolTemperature + 1, 31);
        socket.emit("mbac_update", currentState);
    } else if (currentState.mode == "dry") {
        currentState.dryIntensity = "high";
        socket.emit("mbac_update", currentState);
    } else if (currentState.mode == "heat") {
        currentState.heatTemperature = Math.min(currentState.heatTemperature + 1, 31);
        socket.emit("mbac_update", currentState);
    }
}
```

結構手抜きな部分あります。
```var socket = io.connect();```
今回、htmlとsocket.ioのurlが同じなので、これだけでsocket.ioに繋がります。
```socket.on("mbac_update"```
でサーバーからのイベントを待ち受けします。サーバーからはエアコンの現状態が送られて来るので、このコールバック内ではエアコンの状態を保持し、その状態によってUIを変化させています。
その下にある〇〇Actionっていう関数はボタンが押されたときの動作です。エアコンの現状態をボタン操作に応じて書き換え、その状態をサーバーに送る動作になってます。

# 実行

Raspberry Pi側では、socket.ioが必要なので、socket.ioをインストールします
```npm install socket.io```
また、以前の記事にあるscanプログラムをコンパイルしてmbac_web.jsと同じディレクトリに入れておく必要があります。
そして、
```node mbac_web.js```
でサーバーを起動させ、ブラウザから
```http://[Raspberry Piのホスト名 or IPアドレス]:8080```
に繋ぎます。
<img width="320" alt="IMG_0045 2.png" src="https://qiita-image-store.s3.amazonaws.com/0/242402/34cb3569-b49f-1d8d-701d-92a1e4fdd95e.png"><img width="320" alt="IMG_0046 2.png" src="https://qiita-image-store.s3.amazonaws.com/0/242402/87774b80-0e56-2548-564f-9e3934bb5e93.png">
このように操作できると思います。
また、socket.ioを使っているので、複数のデバイスで表示させて操作するとUIが同期します。

# その他

## 外出先から操作するには

外出先から操作するには、このNode.jsのサーバーにルーターの外からアクセスできるようにしなければいけません。
そのままポート開放はやばいので、VPNやSSHのトンネルを使いましょう。
僕はiPhoneからはVPN、パソコンからはSSHのトンネルを使ってます。

## あとはお好みで

デザインを変えたり、簡単にアクセスできるようにすればいいと思います。
<img width="374" alt="スクリーンショット 2018-06-28 2.50.02.png" src="https://qiita-image-store.s3.amazonaws.com/0/242402/d3607d95-2dd0-3bd0-829e-cae370adcbce.png">

## 結論

家出るときに部屋のドアは閉めましょう