Unityでシリアルポートを自動検索して通信を行う方法

# はじめに

こんにちは！とある大学でUnityを使用した福祉関係の研究を行っている学部4年の大学生です。
今回は前回に引き続きシリアル通信周りの記事を書いていこうと思います。

前回の記事：https://qiita.com/Datwi/items/2b372c92d483b917d9e9

# 目的・背景

Arduinoに接続したロータリーエンコーダを2つ使って、Unity上の車いすを動かして遊べるようにすることが目的です。

# ソースコード

Unityとarduinoのシリアル通信には4月の配属当初から取り組んでおり、Unity側のスクリプトの修正にはかなりの時間を費やしました💦
C#やUnityなんでほとんど触ったことがない中でのSerial通信、参考サイトのスクリプトをいくつか参考にさせていただき、自分用に同期処理を追加して別のスクリプトからもその値を参照できるようにしました。

※参考サイト

https://qiita.com/Ninagawa123/items/ff8ae0dbb1bf8a120866

https://qiita.com/Ninagawa123/items/f6595dcf788dd316be8a

https://qiita.com/kaz0414/items/30e23e29770471a5f347

https://www.digifie.jp/blog/archives/1580

## Unity側

### 実行環境

OS : Windows11 23H2
Unity Version : Unity5 2022.3.33f1

### スクリプトを書く前に

Unityでシリアル通信を行うにはまずAPI Compatibilityの設定を変更する必要があります。
Edit → Project Setting → Player → Other Setting → Configuration → API Compatibility Level を .NET Frameworkに変更してください。
これでシリアル通信を行うクラスである System.IO.Ports.SerialPortが使用できるようになります。

### スクリプト

Unityでシリアル通信を行うための設定が完了したので次はスクリプトを記述していきます。

今回のスクリプトは自動で使用したいデバイスを特定して接続し、シリアルから送られてくる信号を非同期で待ち続けてくれるスクリプトになっています。

```csharp
using System.Collections.Generic;
using UnityEngine;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

public class SerialHandler : MonoBehaviour
{
    // Deviceのリストを保持するフィールドを追加
    [SerializeField] private List<string> DeviceList = new List<string>();

    private static SerialHandler instance;
    public static SerialHandler Instance
    {
        get
        {
            if (instance == null)
            {
                instance = FindObjectOfType<SerialHandler>();
                if (instance == null)
                {
                    Debug.LogError("MySerialHandler instance not found in the scene. Please ensure MySerialHandler is attached to a GameObject in the scene.");
                }
            }
            return instance;
        }
    }

    [SerializeField] string[] activePorts = null;
    public string portName = null;
    public int baudRate = 9600;

    private SerialPort serialPort;
    private Thread thread;

    private string message;
    public string Message;//ほかのスクリプトからアクセスするためのプロパティ
    private bool isPortOpen;//シリアルポートが既に開かれているかどうかを示すフラグ

async void Awake()
{

        Debug.Log("Start");

        // instanceがすでにあったら自分を消去する。
        if (instance && this != instance)
        {
            Debug.Log("Destroyed");
            Destroy(this.gameObject);
            return; // この行を追加して、以下のコードが実行されないようにする

        }

        instance = this;
        // Scene遷移で破棄されなようにする。      
        DontDestroyOnLoad(this);

        Thread.Sleep(2000);

        if (!isPortOpen)//ポート名がnullの場合、接続を試みる
        {
            portName = await SearchPort();
        }

        Debug.Log(portName + " is setting to Device");
        if (portName != null)
        {
            OpenToRead();// Deviceのポートを開く
            Thread.Sleep(100);// 100ms待つ
            Write("SetDevice");// 使用するDeviceに設定する
            await ReadAsync();// 非同期にデータを読み取る
           
        }
        else
        {
            Debug.Log("Device is not found but try to open COM3");        
            manualOpen();// ポート名がnullの場合、COM3を開く
            Thread.Sleep(100); // 100ms待つ
            Write("setDevice");// 使用するDeviceに設定する
            await ReadAsync();// 非同期にデータを読み取る
        }

}

    private async Task<string> SearchPort()
    {
        Debug.Log("SearchPort Start");
        activePorts = SerialPort.GetPortNames();

        foreach (string port in activePorts)
        {
            Debug.Log(port + " is active");

        }

        for (int i = 0; i < activePorts.Length; i++)
        {
            //Debug.Log(activePorts[i] + " is active");
            portName = activePorts[i]; // activePortの名前をportNameに代入する

            message = null; // メッセージをクリアする
            try
            {
                OpenToSearch();

                Thread.Sleep(2000);

                Write("areyouDevice");//デバイスにシリアル通信で文字列を送信する

                await ReadAreYouDevice();// 非同期にデータを読み取る

                Close();

                if (message != null && message.Contains("iamDevice"))
                {
                    Debug.Log(portName + " is Device!");

                    

                    DeviceList.Add(portName);// Deviceのリストに追加

                    Debug.Log(portName);

                    return portName;// Deviceのポート名を返す
                }
                else
                {
                    Debug.Log(portName + " is not Device");
                    Close();

                    Thread.Sleep(1000);
                }
            }
            catch (System.Exception e)
            {
                Debug.Log(e);
                Close();
            }

        }

        Debug.Log("Device is not found in active ports");
        return null;
    }

    private void OpenToSearch()
    {
        Debug.Log(portName + " will be opened to search");
        serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
        {
            ReadTimeout = 500//タイムアウトの設定
        };
        
        serialPort.Open();//ポートを開く
        isPortOpen = true; // ポートが開かれたことを示す
    }

    private void OpenToRead()
    {
        Debug.Log(portName + " will be opened to read");
        serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
        {
        };
        serialPort.Open();//ポートを開く
        isPortOpen = true; // ポートが開かれたことを示す
    }

    public void Close()
    {
        if (thread != null && thread.IsAlive)//もしスレッドがあったら
        {
            thread.Join();//リードの処理が終わるまで待つ
            thread = null; // スレッドをクリアする
        }
        if (serialPort != null && serialPort.IsOpen)//もしシリアルポートが開いていたら
        {
            serialPort.Close();//ポートを閉じる
            Debug.Log(portName + " is closed");
            serialPort.Dispose();//リソースの解放
            Debug.Log("Resource is cleared");
            serialPort = null; // シリアルポートをクリアする
            isPortOpen = false; // ポートが閉じられたことを示す
        }
    }

    private async Task<string> ReadAreYouDevice()//areyoudeviceのメッセージを読み取る
    {
        message = null;
        await Task.Run(() =>
        {
                if (serialPort != null && serialPort.IsOpen)
                {
                    try
                    {
                        message = serialPort.ReadLine();
                    }
                    catch (System.Exception e)
                    {
                        Debug.LogWarning("1:" + e.Message);
                        Close();
                    }
                }
        });
        return message; // 読み取ったメッセージを返す
        
    }

    public async Task<string> ReadAsync()//非同期でデータを読み取る
    {
        message = null;
        await Task.Run(() =>
        {
            while (true)
            {
                if (serialPort != null && serialPort.IsOpen)
                {
                    try
                    {
                        message = serialPort.ReadLine();
                        //Debug.Log(message + " is received");
                    }
                    catch (System.Exception e)
                    {
                        Debug.LogWarning("2:" + e.Message);
                        Close();
                        break;
                    }
                }
                else
                {
                    break;
                }
            }
        });
        Message = message;
        return message;
    }

    public void Write(string message)
    {
        try
        {
            serialPort.Write(message);
            serialPort.Write("\n");
            Debug.Log(message + " is sent");

        }
        catch (System.Exception e)
        {

            Debug.LogWarning("3:" + e.Message);
        }
    }

    private void OnApplicationQuit()
    {
        Write("exit");
        Close();
    }

    private void manualOpen()
    {
        if(portName == null)
        {
            portName = "COM3";
            OpenToRead();
        }
    }

    public string SendToAnotherScript()
    {
        return message;
    }

}

```

### **インポート**

```csharp
using System.Collections.Generic;
using UnityEngine;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
```

- 必要な名前空間をインポートしています。シリアルポート通信、スレッド、非同期処理、Unityの機能を使用します。

### **クラス定義**

```csharp
public class SerialHandler : MonoBehaviour
```

- `MonoBehaviour` を継承した `SerialHandler` クラスを定義しています。

### **フィールド**

```csharp
[SerializeField] private List<string> DeviceList = new List<string>();
private static SerialHandler instance;
[SerializeField] string[] activePorts = null;
public string portName = null;
public int baudRate = 9600;
private SerialPort serialPort;
private Thread thread;
private string message;
public string Message;
private bool isPortOpen;
```

- シリアルポート通信に必要なフィールドを定義しています。
- `DeviceList` は接続されたデバイスのリストを保持します。
- `instance` はシングルトンパターンのインスタンスを保持します。
- `activePorts` は利用可能なシリアルポートのリストです。
- `portName` は接続するポートの名前です。
- `baudRate` は通信速度を設定します。
- `serialPort` はシリアルポートのインスタンスです。
- `thread` はデータ読み取り用のスレッドです。
- `message` は読み取ったメッセージを保持します。
- `Message` は他のスクリプトからアクセスするためのプロパティです。
- `isPortOpen` はポートが開かれているかどうかを示すフラグです。

### **シングルトンパターン**

```csharp
public static SerialHandler Instance
{
    get
    {
        if (instance == null)
        {
            instance = FindObjectOfType<SerialHandler>();
            if (instance == null)
            {
                Debug.LogError("MySerialHandler instance not found in the scene. Please ensure MySerialHandler is attached to a GameObject in the scene.");
            }
        }
        return instance;
    }
}
```

- シングルトンパターンを実装し、クラスのインスタンスを一つだけに制限します。

### **Awake メソッド**

```csharp
async void Awake()
{

        Debug.Log("Start");

        // instanceがすでにあったら自分を消去する。
        if (instance && this != instance)
        {
            Debug.Log("Destroyed");
            Destroy(this.gameObject);
            return; // この行を追加して、以下のコードが実行されないようにする

        }

        instance = this;
        // Scene遷移で破棄されなようにする。      
        DontDestroyOnLoad(this);

        Thread.Sleep(2000);

        if (!isPortOpen)//ポート名がnullの場合、接続を試みる
        {
            portName = await SearchPort();
        }

        Debug.Log(portName + " is setting to Device");
        if (portName != null)
        {
            OpenToRead();// Deviceのポートを開く
            Thread.Sleep(100);// 100ms待つ
            Write("SetDevice");// 使用するDeviceに設定する
            await ReadAsync();// 非同期にデータを読み取る
           
        }
        else
        {
            Debug.Log("Device is not found but try to open COM3");        
            manualOpen();// ポート名がnullの場合、COM3を開く
            Thread.Sleep(100); // 100ms待つ
            Write("setDevice");// 使用するDeviceに設定する
            await ReadAsync();// 非同期にデータを読み取る
        }

}
```

- シーンのロード時に呼び出されるメソッドです。
- インスタンスが既に存在する場合は自身を破棄します。
- シリアルポートの接続を試み、デバイスを設定します。

### **SearchPort メソッド**

```csharp
private async Task<string> SearchPort()
{
        Debug.Log("SearchPort Start");
        activePorts = SerialPort.GetPortNames();

        foreach (string port in activePorts)
        {
            Debug.Log(port + " is active");

        }

        for (int i = 0; i < activePorts.Length; i++)
        {
            //Debug.Log(activePorts[i] + " is active");
            portName = activePorts[i]; // activePortの名前をportNameに代入する

            message = null; // メッセージをクリアする
            try
            {
                OpenToSearch();

                Thread.Sleep(2000);

                Write("areyouDevice");//デバイスにシリアル通信で文字列を送信する

                await ReadAreYouDevice();// 非同期にデータを読み取る

                Close();

                if (message != null && message.Contains("iamDevice"))
                {
                    Debug.Log(portName + " is Device!");

                    DeviceList.Add(portName);// Deviceのリストに追加

                    Debug.Log(portName);

                    return portName;// Deviceのポート名を返す
                }
                else
                {
                    Debug.Log(portName + " is not Device");
                    Close();

                    Thread.Sleep(1000);
                }
            }
            catch (System.Exception e)
            {
                Debug.Log(e);
                Close();
            }
        }

        Debug.Log("Device is not found in active ports");
        return null;
    }
```

- 利用可能なシリアルポートを検索し、デバイスを見つけます。
- デバイスが見つかった場合、そのポート名を返します。

### **OpenToSearch メソッド**

```csharp
private void OpenToSearch()
{
        Debug.Log(portName + " will be opened to search");
        serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
        {
            ReadTimeout = 500//タイムアウトの設定
        };
        
        serialPort.Open();//ポートを開く
        isPortOpen = true; // ポートが開かれたことを示す
    }
```

- デバイスを検索するためにシリアルポートを開きます。
- シリアルポートから返答がない場合はタイムアウトして次のポートに再接続するようにしています。

### **OpenToRead メソッド**

```csharp
private void OpenToRead()
{
        Debug.Log(portName + " will be opened to read");
        serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
        {
        };
        serialPort.Open();//ポートを開く
        isPortOpen = true; // ポートが開かれたことを示す
    }
```

- デバイスから返答があった場合データを読み取るためにシリアルポートを再度開きます。

### **Close メソッド**

```csharp
public void Close()
{
        if (thread != null && thread.IsAlive)//もしスレッドがあったら
        {
            thread.Join();//リードの処理が終わるまで待つ
            thread = null; // スレッドをクリアする
        }
        if (serialPort != null && serialPort.IsOpen)//もしシリアルポートが開いていたら
        {
            serialPort.Close();//ポートを閉じる
            Debug.Log(portName + " is closed");
            serialPort.Dispose();//リソースの解放
            Debug.Log("Resource is cleared");
            serialPort = null; // シリアルポートをクリアする
            isPortOpen = false; // ポートが閉じられたことを示す
        }
    }
```

- シリアルポートとスレッドを閉じ、リソースを解放します。

### **ReadAreYouDevice メソッド**

```csharp
private async Task<string> ReadAreYouDevice()
{
        message = null;
        await Task.Run(() =>
        {
                if (serialPort != null && serialPort.IsOpen)
                {
                    try
                    {
                        message = serialPort.ReadLine();
                    }
                    catch (System.Exception e)
                    {
                        Debug.LogWarning("1:" + e.Message);
                        Close();
                    }
                }
        });
        return message; // 読み取ったメッセージを返す       
    }
```

- デバイスへ送ったデバイス判定メッセージを非同期で読み取ります。

### **ReadAsync メソッド**

```csharp
public async Task<string> ReadAsync()
{
        message = null;
        await Task.Run(() =>
        {
            while (true)
            {
                if (serialPort != null && serialPort.IsOpen)
                {
                    try
                    {
                        message = serialPort.ReadLine();
                        //Debug.Log(message + " is received");
                    }
                    catch (System.Exception e)
                    {
                        Debug.LogWarning("2:" + e.Message);
                        Close();
                        break;
                    }
                }
                else
                {
                    break;
                }
            }
        });
        Message = message;
        return message;
    }
```

- 接続されたデバイスから送られてくるデータを非同期で読み取ります。

### **Write メソッド**

```csharp
public void Write(string message)
{
        try
        {
            serialPort.Write(message);
            serialPort.Write("\n");
            Debug.Log(message + " is sent");

        }
        catch (System.Exception e)
        {

            Debug.LogWarning("3:" + e.Message);
        }
    }
```

- デバイスにメッセージを送信します。

### **OnApplicationQuit メソッド**

```csharp
private void OnApplicationQuit()
    private void OnApplicationQuit()
    {
        Write("exit");
        Close();
    }
```

- アプリケーション終了時にシリアルポートを閉じます。

### **manualOpen メソッド**

```csharp
private void manualOpen()
   {
        if(portName == null)
        {
            portName = "COM~";//任意のポート名
            OpenToRead();
        }
    }
```

- ポート名が設定されていない場合に指定したポートを開きます。
- ここのポート名はそれぞれの環境で変更してください。

### **SendToAnotherScript メソッド**

```csharp
public string SendToAnotherScript()
    {
        return message;
    }
```

- シリアル通信で受信したデータを別のスクリプトからも使用できるようにします。

## Arduino側

シリアル通信を受信するArduino側のスクリプトです。
Unityから送られくるAreyouDeviceに返答する処理が必要になってくるので、その処理を記述します。

```arduino
String SerialString = "";       // シリアル通信で受信する文字列
String Commands[3] = { "\0" };  // 分割された文字列を格納する配列
unsigned long searchStartTime = 0;//ポート検索の
unsigned long searchEndTime = 0;

bool connectingStatus = false;//接続状態

void setup() {
	Serial.begin(9600);
	
	  while (!connectingStatus) {  // シリアルポートに接続されているかどうか確認
    connectingStatus = checkConnected();
  }
}

void loop() {

	//任意の処理

}

bool checkConnected() {
	if (Serial.available() > 0) { // シリアルバッファにデータがあるか確認
		SerialString = readStringUntil('¥n');//改行が送られてくるまでシリアルを読み取る
		if (SerialString == "areyouDevice")//シリアルからデバイス確認文が送られてきたら
		{
			Serial.println("iamDevice");//返答を行う
			return true;//trueを返す
		}
	}
	return false;// デフォルトでfalseを返す
}
```

### 変数の定義

```arduino
String SerialString = "";       // シリアル通信で受信する文字列
String Commands[3] = { "\0" };  // 分割された文字列を格納する配列
unsigned long searchStartTime = 0; // ポート検索の開始時間
unsigned long searchEndTime = 0;   // ポート検索の終了時間
bool connectingStatus = false; // 接続状態
```

### setup関数

```arduino
void setup() {
    Serial.begin(9600); // シリアル通信を9600ボーレートで開始
    
    while (!connectingStatus) {  // シリアルポートに接続されているかどうか確認
        connectingStatus = checkConnected(); // 接続状態を確認
    }
}
```

- `Serial.begin(9600)` でシリアル通信を9600ボーレートで開始します。
- `connectingStatus` が `true` になるまで `checkConnected` 関数を呼び出して接続状態を確認します。

### loop関数

```arduino
void loop() {
    // 任意の処理
}
```

• メインループで、ここに任意の処理を追加できます。

### checkConnected関数

```arduino
bool checkConnected() {
    if (Serial.available() > 0) { // シリアルバッファにデータがあるか確認
        SerialString = readStringUntil('¥n'); // 改行が送られてくるまでシリアルを読み取る
        if (SerialString == "areyouDevice") { // シリアルからデバイス確認文が送られてきたら
            Serial.println("iamDevice"); // 返答を行う
            return true; // trueを返す
        }
    }
    return false; // デフォルトで false を返す
}
```

- シリアルバッファにデータがあるかを確認し、データがあれば改行まで読み取ります。
- 受信した文字列が `"areyouDevice"` であれば、 `"iamDevice"` と返答し、`true` を返します。
- それ以外の場合は `false` を返します。

# さいごに

いかがだったでしょうか。
上記のUnityとArduinoそれぞれのスクリプトを使用することでUnity上から自動で使用するデバイスを検索し、シリアル通信を行うことができるようになります。
かなり長時間ハマってしまったことなので、今後困る誰かの役に立てれば幸いです。

最後までご覧いただきありがとうございました！まだまだ勉強不足なため、不備等ありましたらご指摘いただけますと幸いです。