📞Arduinoのシリアル通信 # 2

ArduinoSerial2 Arduino

ArduinoとWin-Formで実現する非同期シリアル通信制御

PC(Windows)からArduinoへ命令を送り、Arduinoでの処理が終わるまでUIをブロックせずに待機する。これは、実用的なツール開発において非常に重要なテクニックです。これができないと処理中Formが固まってしまい行儀の悪いアプリになってしまいます。

今回は、LEDの点灯命令を送り、Arduinoから「処理完了」が返ってくるまでを非同期で待機するシステムを構築してみました。本来LEDの点灯は時間のかからない処理なので非同期処理は不要なのですが、センサーの反応待ちとか、モーターの動作などを想定して、数秒のディレイ時間を入れて、非同期処理を実感できるようになっています。

1. システム構成と通信プロトコル

今回の通信ルール(プロトコル)を以下のように定義します。


2. Arduino側の実装

Arduino側では、シリアル入力を監視し、特定の文字列を受け取ったらLEDを制御します。random()関数を使用して、0~9秒のランダムな待機時間をシミュレートしています。

C++
// Arduino用スケッチ
const int LED_PIN = 13; // 内蔵LED

void setup() {
  Serial.begin(9600);
  pinMode(LED_PIN, OUTPUT);
  randomSeed(analogRead(0)); // ランダムの初期化
}

void loop() {
  if (Serial.available() > 0) {
    String command = Serial.readStringUntil('\n');
    command.trim();

    if (command == "LED_ON") {
      // 1. LED点灯
      digitalWrite(LED_PIN, HIGH);

      // 2. 0~9秒のランダムディレイ
      long delayTime = random(0, 10000);
      delay(delayTime);

      // 3. LED消灯
      digitalWrite(LED_PIN, LOW);

      // 4. 終了コマンドを返す
      Serial.println("FINISHED");
    }
  }
}

3. Windows Forms側の実装 (C#)

C#側では、SerialPortクラスを使用します。予めNugetでSystem.IO.Portsをインストールする必要があります。

ポイントは、イベント駆動型のシリアル受信をTaskに変換し、awaitで待機できるようにしている点です。完了待ちの間にFormの位置を移動してみて、画面が固まっていないことを確認してください。

フォームの配置

  • btnLedOn (Button)

ソースコード

C#
using System.IO.Ports;

namespace SerialAsyncApp
{
    public partial class MainForm : Form
    {
        private SerialPort serialPort = new SerialPort();

        // Arduinoからの応答を待機するためのプロミス
        private TaskCompletionSource<string> _responseTaskSource;

        public MainForm()
        {
            InitializeComponent();
            // シリアルポートの設定(環境に合わせてポート名は変更してください)
            serialPort.PortName = "COM3";
            serialPort.BaudRate = 9600;
            serialPort.DataReceived += SerialPort_DataReceived;
        }

        private async void btnLedOn_Click(object sender, EventArgs e)
        {
            if (!serialPort.IsOpen)
            {
                try { serialPort.Open(); }
                catch (Exception ex) { MessageBox.Show(ex.Message); return; }
            }

            btnLedOn.Enabled = false; // 二重押し防止
            UpdateStatus("Arduinoの処理を待機中...");

            try
            {
                // 応答待機用のTaskを初期化
                _responseTaskSource = new TaskCompletionSource<string>();

                // コマンド送信
                serialPort.WriteLine("LED_ON");

                // 非同期でFINISHEDが返ってくるのを待つ(UIはフリーズしません)
                string result = await _responseTaskSource.Task;

                if (result == "FINISHED")
                {
                    UpdateStatus("完了しました!");
                }
            }
            finally
            {
                btnLedOn.Enabled = true;
            }
        }

        private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            // Arduinoからデータが届いたら読み取る
            string response = serialPort.ReadLine().Trim();

            // 待機中のTaskに応答をセットして完了させる
            _responseTaskSource?.TrySetResult(response);
        }

        private void UpdateStatus(string message)
        {
            // 必要に応じてラベルなどに状態を表示
            this.Text = message;
        }
    }
}

4. プログラムの解説

非同期処理の肝:TaskCompletionSource

通常、シリアル通信の DataReceived イベントは別スレッドで発生するため、そのままでは「ボタンを押した後のメソッド内」で結果を受け取ることが困難です。

そこで TaskCompletionSource を使用しています。

  1. ボタンクリック時に _responseTaskSource を作成し、await でその完了を待ちます。
  2. シリアルデータが届いた瞬間、DataReceived イベント内で TrySetResult を呼び出します。
  3. await していた箇所に処理が戻り、続きが実行されます。

これにより、**「送信 → 応答待ち → 次の処理」**という一連の流れを、UIをフリーズさせることなく一本のメソッド内に記述できます。

注意点

  • ポート開放: serialPort1.Open() は適切なタイミング(FormLoad時など)で行うのが一般的です。
  • 例外処理: 通信エラーやタイムアウト処理を追加することで、より堅牢なプログラムになります。

5. 蛇足 ー 少し昔のプログラム

今は、TaskCompletionSource(TCS)を使用するのが一般的ですが、伝統的なプログラムも作成してみました。非同期処理というよりもイベント駆動側の処理になっており、「送信する処理」と「受信後の処理」がソースコード上で完全に分離してしまいます。

C#
using System.IO.Ports;

namespace SerialEventApp
{
    public partial class MainForm : Form
    {
        SerialPort serialPort = new SerialPort();

        public MainForm()
        {
            InitializeComponent();
            // シリアルポートの基本設定
            serialPort.PortName = "COM3";
            serialPort.BaudRate = 9600;

            // データ受信イベントを紐付け
            serialPort.DataReceived += serialPort_DataReceived;
        }

        // ボタンが押された時の処理
        private void btnLedOn_Click(object sender, EventArgs e)
        {
            if (!serialPort.IsOpen)
            {
                try { serialPort.Open(); }
                catch (Exception ex) { MessageBox.Show(ex.Message); return; }
            }

            // 1. ボタンを無効化して「待ち状態」にする
            btnLedOn.Enabled = false;
            this.Text = "Arduinoの処理を待機中...";

            // 2. コマンド送信
            serialPort.WriteLine("LED_ON");

            // ここでメソッドは終了。あとは「受信イベント」が呼ばれるのを待つ。
        }

        // データを受信した時に呼ばれる(別スレッドで動作)
        private void serialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            try
            {
                // データの読み取り
                string response = serialPort.ReadLine().Trim();

                if (response == "FINISHED")
                {
                    // UIスレッドに処理を戻して画面を更新する
                    this.Invoke(new Action(() =>
                    {
                        ProcessFinished();
                    }));
                }
            }
            catch (Exception ex)
            {
                // エラー処理
            }
        }

        // Arduino側の処理が終わった時にUI側でやりたいこと
        private void ProcessFinished()
        {
            btnLedOn.Enabled = true;
            this.Text = "完了しました!";
        }
    }
}

コードの違い

  1. TCS+async/awaitを使ったコード
    • 仕組み: イベントをTask(約束)に変換して、あたかも「その場で待っている」かのように書ける。
    • メリット: 「上から下へ」順番にコードが読めるため、ロジックの全体像が把握しやすい。条件分岐やループも自然に書ける。
    • デメリット: 内部で何が起きているか(スレッドの切り替わりなど)を理解していないと、デバッグが難しい場合がある。
  2. イベント駆動型のコード
    • 仕組み: 送信したら一旦おしまい。データが来たら「割り込み」のように処理が走る。
    • メリット: 古いバージョンの.NETでも動作し、シリアル通信の本来の動き(非同期なイベント)に近い。
    • デメリット: 処理がバラバラのメソッドに分かれるため、複雑な手順(例:Aを送って、返事が来たらBを送って、また返事を待つ…)になると、コードが非常に読みづらくなります(通称:コールバック地獄)。

this.Invokeが必要な理由

serialPort_DataReceived()内、50行目にInvoke()を使用していますが、これは、SerialPortのDataReceivedイベントが、UIが走っているメインスレッドとは別のサブスレッドで動いているからです。画面上のコントロール(ボタンなど)は、メインスレッドからしか操作できないため、Invoke()を介すことで操作することで回避しています。TCSを使用するコードでは裏側で処理してくれるためその辺の配慮は必要ありません。

以上、蛇足が長くなりました…。