半二重っぽいシリアル通信を実装した話

# 背景

PLCみたいな、シングルタスクに近い機器をシリアル通信で制御する場合、
本来は全二重のはずのシリアル通信で相手の機器の反応を待ってから次の信号を送ったほうが良いことがある。

そのような処理を.Net FrameworkとWindows Formアプリでやったのでメモとしてコード例を残しておく。
WPFだったら[以前作成したDispatcher](https://qiita.com/mickie895/items/4a19f897ffe2b03eab63)を流用できるからそっちを使ったほうが良いが、
産業用のPCだと主にコスト面でスペックが切り詰められることが多く、
Formアプリもそのような比較的非力なPCでWindowsアプリを動かすときに選択肢に上がってくることが多い。

# コード

今回はこの記事を見ている人が使いやすいように、基底クラスだけ掲載しておく。

<details><summary>長いため折りたたみ</summary>
<div>

```cs:HalfDuplexSerialBase.cs
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.IO.Ports;
using System.Threading;

namespace HalfDuplexSerial
{
    public abstract class HalfDuplexSerialBase
    {
        private ConcurrentQueue<byte[]> MessageQueue;

        private readonly int _timeoutMills;

        private SerialPort _port;

        private Thread _loopThread;

        /// <summary>
        /// スレッドを立ち上げるのに必要な情報を準備する
        /// </summary>
        /// <param name="port"></param>
        /// <param name="timeoutMills"></param>
        protected HalfDuplexSerialBase(SerialPort port, int timeoutMills)
        {
            MessageQueue = new ConcurrentQueue<byte[]>();
            _port = port;
            _timeoutMills = timeoutMills;
        }

        /// <summary>
        /// メッセージキューの送信予定リストの末尾に送信データを登録する
        /// </summary>
        /// <param name="data"></param>
        public void PostData(byte[] data)
            => MessageQueue.Enqueue(data);

        /// <summary>
        /// メッセージループを起動する
        /// </summary>
        public void StartMessageLoop()
        {
            if (_loopThread != null)
            {
                throw new InvalidOperationException("すでにスレッドは立ち上げられています。");
            }

            if (!_port.IsOpen)
            {
                // ポートのオープン処理は別スレッドで行わないほうがデバッグ時の対応はかんたん
                _port.Open();
            }

            _loopThread = new Thread(new ThreadStart(MainLoop));
            _loopThread.Start();
        }

        /// <summary>
        /// 実際のメッセージループ
        /// </summary>
        private void MainLoop()
        {
            while (true)
            {
                if (MessageQueue.TryDequeue(out byte[] message))
                {
                    SendData(message);
                }
                // 制御を別スレッドに渡す。
                Thread.Sleep(0);
            }
        }

        /// <summary>
        /// 各信号を送信してから受信するまでの処理
        /// </summary>
        /// <param name="message"></param>
        private void SendData(byte[] message)
        {
            // 半二重型の通信で、PC側が信号を送る前に送られている信号はノイズと考えてよいはずなので廃棄する。
            _port.DiscardInBuffer();
            _port.Write(message, 0, message.Length);
            
            DateTime timeoutLimit = DateTime.Now + TimeSpan.FromMilliseconds(_timeoutMills);
            List<byte> recievedData = new List<byte>();
            
            while(DateTime.Now <= timeoutLimit)
            {
                while(_port.BytesToRead > 0)
                {
                    recievedData.Add((byte)_port.ReadByte());
                    if (DataHasRecieved(message, recievedData))
                    {
                        OnDataRecieved(message, recievedData);
                        return;
                    }
                }
                // 制御を別スレッドに渡す。
                Thread.Sleep(0);
            }

            OnRecieveTimeouted(message);
            return;
        }

        /// <summary>
        /// メッセージの受信が完了したときのイベントハンドラ
        /// </summary>
        public event EventHandler<DataRecievedEventArgs> DataRecieved;

        private void OnDataRecieved(byte[] send, List<byte> recieve)
        {
            DataRecieved?.Invoke(this, new DataRecievedEventArgs(send, recieve));
        }

        /// <summary>
        /// メッセージを送信したがその返答が来ていないときのイベントハンドラ
        /// </summary>
        public event EventHandler<SendDataEventArgs> RecieveTimeoutHappen;

        private void OnRecieveTimeouted(byte[] send)
        {
            RecieveTimeoutHappen?.Invoke(this, new SendDataEventArgs(send));
        }

        /// <summary>
        /// このバイト列が受信完了しているかどうかをチェックする
        /// </summary>
        /// <param name="sendedItem">送信に使用したバイト列</param>
        /// <param name="recievedItem">受信しているバイト列</param>
        /// <returns></returns>
        protected abstract bool DataHasRecieved(byte[] sendedItem, List<byte> recievedItem);
        
        /// <summary>
        /// スレッドを停止する。
        /// この関数を使ってスレッドを止めないとプロセスが終わらなくなるから注意
        /// </summary>
        public void StopMessageLoop()
        {
            if (_loopThread.ThreadState == ThreadState.Running)
            {
                _loopThread.Abort();
            }
        }
    }

    public class SendDataEventArgs: EventArgs
    {
        public byte[] SendedItem { get; }
        public SendDataEventArgs(byte[] send)
        {
            SendedItem = send;
        }
    }

    public class DataRecievedEventArgs: SendDataEventArgs
    {
        public ReadOnlyCollection<byte> RecievedItem { get; }

        public DataRecievedEventArgs(byte[] send, List<byte> recieve): base(send)
        {
            RecievedItem = new ReadOnlyCollection<byte>(recieve);
        }
    }

}

```

</div></details>

# 使用方法

1. このクラスを継承したクラスを作成し、DataHasRecieved関数に受信の終了条件を記載する
1. PostData関数で受信データの送信予定を登録する
1. DataRecievedおよびRecieveTimeoutHappenイベントを購読することにより結果を取得する
1. メインのアプリの終了時にStopMessageLoopを実行するようにしておく(Disposeパターンで停止させようとしても無理だった)

なお、UIスレッド外からのUIの操作はFormアプリに限らず大抵禁止されている。
今回のイベントの発生源はUIスレッド外なので、UIの操作は[Control.Invokeメソッド](https://docs.microsoft.com/ja-jp/dotnet/api/system.windows.forms.control.invoke?view=windowsdesktop-6.0)を利用すること。

# ポイント

今回の実装で注意した点は次の通り

- 複数のスレッド間でデータを書き換えることができてしまうと値が文字通り「なんでもあり」になってしまう
    - 外部クラス・外部スレッドとやり取りするデータはこのメッセージループでは書き換えないデータにする
    - メッセージキューだけは複数のスレッドが操作する必要があるため、[ConcurrentQueue](https://docs.microsoft.com/ja-jp/dotnet/api/system.collections.concurrent.concurrentqueue-1?view=net-6.0)を利用してスレッドセーフ性を確保する

# 余談

今回は記事に乗せるために業務知識を取り除いているが、
実際にこのコードを使う場合はbyte[]ではなく別途クラスを作ったほうが良さそう。