C#でサーバ間通信をするスマートリモコンを作った話

ラズパイ（Raspberry Pi Zero WH）を利用して、Windows PCからの命令によって家の照明器具をオンオフできるデバイスを作りました。
全体の構成は以下のような感じです。
意外とC#でもスマートデバイスの作成ができるんだということを知っていただければと思います。
![SmartDeviceArchitecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/b4e0b571-536b-9084-79db-59f55517d3c2.png)
ほぼC#しか触ったことがなく、ラズパイやサーバの知識がほとんどない状態で様々なサイトの情報を頼りに構築してみました。
自分が取り組んだ順番に、システムの設計をお話していこうと思います。

#1. デバイスの準備
最初の目標として、「家電を赤外線通信によって操作するデバイス」を作成することを考えました。
そのような条件を満たすようなデバイスの構成を考えていたところ、このようなサイトが見つかりました。
[格安スマートリモコンの作り方](https://qiita.com/takjg/items/e6b8af53421be54b62c9)

スマートリモコンを1から作る方法について、必要な部品からすべて解説されている素晴らしい記事でした。
これを参考に部品を調達し、記事で挙げられている赤外線送信・受信のサンプルが実行できることを確認しました。
このプログラムをラズパイの外部から起動させることが今回の目的です。

#2. ssh接続およびファイル共有設定
以後の作業を快適にするために、ラズパイの操作をWindows PC上で行えるように、ssh接続の有効化を行いました。
[Windows 10からRaspberry PiにSSH接続してみる（公開鍵認証）](http://imamachi-n.hatenablog.com/entry/2018/06/17/104419)

また、C#でコーディングを行ったアプリケーションをラズパイに転送するために、ファイルサーバ化を行いました。
[Raspberry Piを使ってファイルサーバーを立てる](https://qiita.com/ARBALEST000/items/78f459567e1e90de99e5)

ssh接続は特に問題なく行えましたが、Sambaをインストールしファイルサーバを構築するのに非常にてこずりました。
そもそもWindows上でラズパイが見つからない、、、DNSサーバでは存在が確認できるけどWindows PCからの接続が拒否される、、、などなど。

そんな中で、何が問題なのかを解決するのに、こちらのサイトが非常に参考になりました。
[日本 Samba ユーザ会-Sambaが動作しないときの診断方法](http://www.samba.gr.jp/doc/diag.html)

問題になりそうな部分をpingから順に非常に丁寧に解説されており、どこでエラーが起こっているかを細かく確認することができました。
結局、エラーの原因は大きく
1. smb.confの設定ミス
2. Windows側のセキュリティ設定
でした。特にWindows側のセキュリティはWindows10になってから強固になっており、それに対する対応に苦労しました。

結果的には
1. SMB1.0の有効化
2. 資格情報マネージャにユーザ名・パスワードを登録する（参考：[Win 10 で共有フォルダーにアクセスできなかった件](https://www.syuheiuda.com/?p=4414)）
3. （2の代わりに）レジストリの設定を変更し、アクセス権限無しでも接続可能にする（参考：[NASに接続できない！原因はSMB1.0ではなかった！](https://www.nedia.ne.jp/blog/2019/04/02/13919)）

3の方法は楽ですがセキュリティリスクがあるので、自分は原因確認として一時的に設定を変更し、これが問題であることを確認したうえで2を行いました。

#3. Pythonアプリを実行するC#アプリケーションの作成
.Netで作成されたアプリケーションでもラズパイのGPIOは操作できるらしいですが、#1で利用したPythonアプリケーションで所望の動作が実現できることを確認できたのでPythonアプリケーションをC#から実行することにしました。

こんな感じのコードを書きました。

```pythonlaunch.cs
// プロセスの立ち上げ
var process = new Process();
{
    StartInfo = new ProcessStartInfo(PYTHON_INTERPRITER)
        {
            UseShellExecute = false,
            RedirectStandardOutput = true,
            Arguments = ARGUMENTS
        }
}

// プロセスの実行
process.Start();

var sr = process.StandardOutput;
Log($"console log: {sr.ReadLine()}");

// 終了まで待ってから切断する
process.WaitForExit();
process.Close();
```

コード中の`PYTHON_INTERPRITER`はpythonのインタプリタが存在しているアドレスです。

```
$ which python
```

で確認できます。
`ARGUMENTS`はコマンドの引数で、#1のアプリを実行したい場合、`python3 irrp.py -r -g18 -f codes light:on --no-confirm --post 130`と書けば大丈夫です。

#4. C#（.NET）アプリケーションをラズパイ上で実行する
.NETアプリケーションをラズパイ上で実行するためには、Monoのインストールが必要です。

```
$ sudo apt-get install mono-complete
```

これでMonoをインストールした後なら、.NETアプリケーションを

```
$ mono application.exe
```

のように起動することができます。

#5. デバイスへの命令をMQTTで送受信する
一般的なIoTデバイスに近いような仕様にしたかったので、Google Homeでも使われるGoogle IoT Coreのドキュメントを確認してみました。
[Google Cloud IoT Core documentation](https://cloud.google.com/iot/docs/?hl=ja)

あまり詳しく確認していませんが、どうやらHTTPとMQTTという通信プロトコルでやり取りができそうです。
MQTTというのはIBMが開発したIoTデバイスの通信規格で、メッセージを多対多でやり取りするのに優れているそうです。
[IoT開発未経験者向け！ IoTで注目を浴びるプロトコル、MQTTとは？](https://amg-solution.jp/blog/15787)

今回はMQTTを利用します。
MQTTはCやJava、Pythonなど多くの言語で実装されていますが、C#で利用するためにはM2Mqttというラッパーを利用します。
[C# (.net) で使える MQTT クライアント](https://www.sukerou.com/2018/08/c-net-mqtt-paho-m2mqtt.html)

MQTTはメッセージをやり取りするためにサーバを立てる必要があります。
今回はCloudMQTTという外部サーバを利用しました。無料で5台まで接続できる上、自分で建てるわけではないのでセキュリティリスクも少なくとても便利です。

こんな感じで初期化処理を行います。
ApplicationIDは各デバイスにユニークなもので、Guidクラスが便利です。
プロトコルとセキュリティ設定はよくわかりませんが、このような形にしたところうまくいきました。

```sharepart.cs
APPLICATION_ID = Guid.NewGuid().ToString();
var _mqttClient = new MqttClient(MQTT_BROKERHOSTNAME, PORT, false, null, null, MqttSslProtocols.SSLv3);
_mqttClient.Connect(APPLICATION_ID, USERNAME, PASSWORD);
```

受信側はこのように受信時のイベントハンドラを設定し、どのトピックを購読するかを指定します。

```receiver.cs
_mqttClient.MqttMsgPublishReceived += (sender, e) => {
    OnReceivedData(Encoding.UTF8.GetString(e.Message));
};
_mqttClient.Subscribe(new[] { MQTT_TOPIC }, new byte[] { 2 });
```

送信側は初期化処理の後以下のようなスクリプトを1行書くだけでメッセージが送信できました。

```sender.cs
_mqttClient.Publish(MQTT_TOPIC, Encoding.UTF8.GetBytes(data), 0, false);
```

送受信はバイナリでやり取りをしますが、Jsonからデシリアライズ・シリアライズしました。
このコードを持つアプリケーションをWindows PC側、ラズパイ側双方に入れ、実行することでリモートでメッセージをやり取りできました。
一応双方のコードをのせておきます。

```RasPiServer.cs
using System;
using System.Diagnostics;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using uPLibrary.Networking.M2Mqtt;

namespace RasPiServer
{
    internal class Program
    {
        private const string MQTT_ORDER_TOPIC = "/IR/Order";

        private const string PYTHON_INTERPRITER = @"/share/RasPiServer/python.exe";
        private const string PYTHON_APPLICATION = @"/share/RasPiServer/irrp.py";
        private const string PYTHON_SAVE = @"/share/RasPiServer/codes";

        private string MQTT_BROKERHOSTNAME;
        private int PORT;
        private string APPLICATION_ID;
        private string USERNAME;
        private string PASSWORD;
        private static MqttClient _mqttClient;

        public Program()
        {
            // M2Mqttの起動
            APPLICATION_ID = Guid.NewGuid().ToString();
            _mqttClient = new MqttClient(MQTT_BROKERHOSTNAME, PORT, false, null, null, MqttSslProtocols.SSLv3);
            _mqttClient.Connect(APPLICATION_ID, USERNAME, PASSWORD);
            
            // 購読設定
            _mqttClient.MqttMsgPublishReceived += (sender, e) => {
                OnReceivedData(Encoding.UTF8.GetString(e.Message));
            };

            _mqttClient.Subscribe(new[] { MQTT_ORDER_TOPIC }, new byte[] { 2 });
        }
        
        private static void Main(string[] args)
        {
            var program = new Program();
            Console.WriteLine("[RASPISERVER] LAUNCHED");
            
            while (true)
            {
                System.Threading.Thread.Sleep(500);
            }
        }

        private void Log(string message)
        {
            Console.WriteLine(message);
        }

        private void OnReceivedData(string message)
        {
            var data = new RasPiData();

            // 受信データのデシリアライズ
            try
            {
                data = JsonConvert.DeserializeObject<RasPiData>(message);
            }
            catch (Exception e)
            {
                Log($"unexpected message arrived : {e.Message}");
                return;
            }

            // メッセージが指定のJson形式だった場合
            try
            {
                if (data.MessageType == MessageType.Record || data.MessageType == MessageType.PlayBack)
                {
                    PlayBackAndRecord(data.MessageType, data.Message);
                    Log("Process succeeded.");
                }
                else
                {
                    Log($"unexpected message arrived : {data.Message}");
                }
            }
            catch (Exception e)
            {
                Log($"Process error: {e.Message}");
                return;
            }
        }

        private void PlayBackAndRecord(MessageType type, string id)
        {
            // 命令の種類を受け、Pythonアプリケーションの引数を指定
            var arguments = "";
            switch (type)
            {
                case MessageType.Record:
                    arguments = $"{PYTHON_APPLICATION} -r -g18 -f {PYTHON_SAVE} {id} --no-confirm --post 130";
                    break;
                case MessageType.PlayBack:
                    arguments = $"{PYTHON_APPLICATION} -p -g17 -f {PYTHON_SAVE} {id}";
                    break;
                default:
                    throw new ArgumentOutOfRangeException(nameof(type), type, null);
            }

            Log($"PlayBackAndRecord start. mode: {type}");

            // Pythonアプリケーションの起動・実行
            var process = new Process()
            {
                StartInfo = new ProcessStartInfo(PYTHON_INTERPRITER)
                {
                    UseShellExecute = false,
                    RedirectStandardOutput = true,
                    Arguments = arguments
                },
            };

            process.Start();

            var sr = process.StandardOutput;
            Log($"console log: {sr.ReadLine()}");

            process.WaitForExit();
            process.Close();
        }
    }
}

```

```RasPiClient.cs
using System;
using System.Text;
using Newtonsoft.Json;
using uPLibrary.Networking.M2Mqtt;

namespace RasPiClient
{
    internal class Program
    {
        private const string MQTT_ORDER_TOPIC = "/IR/Order";
        private const string MQTT_LOG_TOPIC = "/IR/Logs";

        private string MQTT_BROKERHOSTNAME;
        private int PORT;
        private string APPLICATION_ID;
        private string USERNAME;
        private string PASSWORD;
        private static MqttClient _mqttClient;

        public Program()
        {
            // M2Mqttの起動
            APPLICATION_ID = Guid.NewGuid().ToString();
            _mqttClient = new MqttClient(MQTT_BROKERHOSTNAME, PORT, false, null, null, MqttSslProtocols.SSLv3);
            _mqttClient.Connect(APPLICATION_ID, USERNAME, PASSWORD);
        }
        
        private static void Main(string[] args)
        {
            // コンソールで何の処理を行うかをユーザに尋ねる
            var program = new Program();
            Console.WriteLine("[RASPICLIENT] LAUNCHED");
            
            while (true)
            {
                Console.WriteLine("Type \"record\" or \"playback\" to do action.");
                var incoming = Console.ReadLine();

                MessageType type;
                switch (incoming)
                {
                    case "record":
                        Console.WriteLine("Set parameter :");
                        type = MessageType.Record;
                        break;
                    case "playback":
                        Console.WriteLine("Set parameter :");
                        type = MessageType.PlayBack;
                        break;
                    default:
                        continue;
                }

                var message = Console.ReadLine();
                var param = new RasPiData(){Message = message, MessageType = type};
                _mqttClient.Publish(MQTT_ORDER_TOPIC, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(param)));
            }
        }
    }
}
```

送受信データは以下のようなstringとenumの2情報から構成されます。
enumのシリアライズ・デシリアライズは少々処理が複雑になりますが、その処理について、今回は本題ではないのでここでは省きます。

スクリプトを読んでいただければすぐわかりますが、Client側のコンソールアプリケーションで`record`または`playback`と入力し、その後その赤外線メッセージのタイトルを入力することで命令の登録・実行ができます。

#終わりに
今回はC#によるコーディングのみでラズパイを用いたスマートリモコンを作成してみました。
あまりにもサーバやラズパイに関する知識がなかったのでC#で書きましたが、ほかの言語での実装にもチャレンジしてみたいです。
ラズパイ側のコードをラズパイ起動時に実行させるようにすればPlug-To-Playにもなりそうです。

また、今回MQTTというプロトコルを初めて勉強しましたが、これを利用してAlexaやGoogle Homeといったスマートアシスタントとの連携にもチャレンジしてみたいと思っています。

C#で作ってみたので、Unityにも容易に持ち込めるのでそことの連携もいろいろできそうです。