【個人開発】鉄道模型(Nゲージ）をセンサーで自動制御してみた

# 1. はじめに
今回は、「[鉄道模型(Nゲージ）をRaspberry Piで動かしてみた](https://qiita.com/takeo-t/items/193a1d0985e8309f0165)」の続編を執筆させていただきました。

前回は加速・減速を一定時間続けるのみの制御でしたが、今回はレールにセンサーを組み込み、より本物の鉄道の動きに近い運転を目指そうと思います。
![IMG_4644.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/61a4afc3-da21-97bd-e312-a8e4f607aba9.jpeg)

※本記事で紹介する方法や情報をお試しいただく際は、ご自身の責任において行ってください。本ブログの内容により発生したいかなる損害やトラブルについて、当方は一切の責任を負いかねますので、予めご了承ください。

# 2. 想定読者
- これからRaspberry Piで遊びたいと考えている方
- プログラミングが好きな方
- 何か制御したいと思っている方
- Nゲージが好きな鉄道ファン

# 3. 制御方法の検討
線路上にセンサを2つ設置した上で、
下記のステップで自動運転を行いたいと思います。
```
①センサー上を車両が通過すると自動停止する

②停止後、車両に反応したセンサーを無効にする

③線路の電源の方向を反転させて、車両の走行方向を変える

④対向のセンサを有効にする

④車両を加速させる
```
①～⑤のステップを5回繰り返し、1本の線路で折り返し運転をする試みです。
![システム構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/4b98e7ea-ccfe-86aa-4e69-9d722eb0b877.png)
# 必要なものを準備
車両を検知するセンサーには[反射型フォトセンサー](https://akizukidenshi.com/catalog/g/g112626/)を使用します。
反射型フォトセンサーは光を利用して物体の有無を検出するセンサーです。
光が入射すると、光の強さに応じて抵抗値が変化するという特性があります。
それを利用して、車両の有無を判定します。
![Image (26).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/1ec29770-e332-0608-af6c-2795a2b7a4b2.jpeg)
また、Raspberry-Piは直接アナログ値の入力ができないので、反射型フォトセンサーとRaspberry-Piの間にこちらの[ADコンバータ MCP3008](https://akizukidenshi.com/catalog/g/g109485/)を組み込み、デジタル値を入力するようにします。
![Image (27).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/92c6b4ca-a172-18b3-2492-8a237fae7aae.jpeg)
# 5. 線路にセンサーを設置
Nゲージ用の線路を加工し、反射型フォトセンサーを設置します。
線路の一部をカットし、センサーを組み込んだ基盤を設置します。
また、線路には最大直流12Vの電圧が印加されるので、短絡防止のために紙で絶縁処理を施します。
![Image (29).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/e2dfe6a9-c210-9faf-b796-4c8c2fd1dee5.jpeg)
さて、反射型フォトセンサーをADコンバータに接続するには抵抗を接続する必要があります。
下図は反射型フォトセンサーの回路図です。（[秋月電子通商より引用](https://akizukidenshi.com/goodsaffix/tpr-105f.pdf)）
TopViewの②の部分には220Ωの抵抗を、④の部分には20kΩの抵抗を取付します。
![フォトセンサー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/7019e6f0-e903-3619-0b1a-dadb86696073.png)
# 6. Raspberry-Piとフォトセンサーの接続
抵抗の取付が終わったら、ADコンバータとRaspberry-Piを接続します。接続の対応表は下記に示します。

引用：[アナログ デジタル コンバーターから値を読み取る](https://learn.microsoft.com/ja-jp/dotnet/iot/tutorials/adc)
<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>MCP3008</th><th>Raspberry Pi GPIO</th></tr></thead><tbody><tr><td>VDD</td><td>3.3V</td></tr><tr><td>VRDF</td><td>3.3V</td></tr><tr><td>AGND</td><td>GND</td></tr><tr><td>CLK</td><td>SCLK</td></tr><tr><td>DOUT</td><td>MISO</td></tr><tr><td>DIN</td><td>MOSI</td></tr><tr><td>CS/SHDN</td><td>CE0</td></tr><tr><td>DGND</td><td>GND</td></tr></tbody></table></figure>

![MCP3008.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/be6530b3-fe0c-094e-d84f-870083ff6fd3.png)

![接続図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/66c20b66-af73-f38b-1656-034022fb79ab.png)

続いて、ADコンバータのCH0とCH1に反射型フォトセンサー付きの線路を接続します。

![Image (31).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/7fa68007-231a-e55b-f88f-9e427513341c.jpeg)

接続が完了したら、センサーからの入力値が正常に受け取ることができているかをテストします。
下記のようなシンプルなテスト用コンソールアプリを作成し、Raspberry-Piに発行した後、実行します。

```./AdcTutorial/Sensor.cs
using Iot.Device.Adc;
using System;
using System.Device.Spi;
using System.Threading;

namespace AdcTutorial
{
    public class Sensor : IDisposable
    {
        private bool disposed = false;
        private readonly SpiDevice spi;
        private readonly Mcp3008 mcp;

        public Sensor()
        {
            // SPI接続の設定
            var hardwareSpiSettings = new SpiConnectionSettings(0, 0);
            spi = SpiDevice.Create(hardwareSpiSettings);
            mcp = new Mcp3008(spi);
        }

        // センサーから値を読み取るメソッド
        public async Task ReadSensorData()
        {
            while (true)
            {
                Console.Clear();
                double valueCh0 = mcp.Read(0);
                Console.WriteLine($"Channel 0: {valueCh0}");
                Console.WriteLine($"Channel 0: {Math.Round(valueCh0 / 10.23, 1)}%");

                double valueCh1 = mcp.Read(1);
                Console.WriteLine($"Channel 1: {valueCh1}");
                Console.WriteLine($"Channel 1: {Math.Round(valueCh1 / 10.23, 1)}%");

                await Task.Delay(500);
            }
        }

        // リソースの解放
        protected virtual void Dispose(bool disposing)
        {
            if (!disposed)
            {
                if (disposing)
                {
                    mcp?.Dispose();
                    spi?.Dispose();
                }
                disposed = true;
            }
        }

        // IDisposableの実装
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }
    }
}

```
![センサ値.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/74a28cfc-49ba-4bb8-a712-ea59f75ebb97.png)
コンソールアプリからRaspberry-Piにセンサ値が入力されていることを確認できました。

# 7. コンソールアプリの改良
[前回開発した自動運転コンソールアプリ](https://qiita.com/takeo-t/items/193a1d0985e8309f0165#7-n%E3%82%B2%E3%83%BC%E3%82%B8%E3%82%92%E5%88%B6%E5%BE%A1%E3%81%99%E3%82%8B%E3%81%9F%E3%82%81%E3%81%AE%E3%82%B3%E3%83%B3%E3%82%BD%E3%83%BC%E3%83%AB%E3%82%A2%E3%83%97%E3%83%AA%E3%82%92%E9%96%8B%E7%99%BA)を発展させます。
アプリケーションに先ほど作成したSensorクラスを追加します。
```
.
├── TrainControl
│   ├── MotorController.cs
│   ├── Sensor.cs //追加
│   ├── Program.cs
│   ├── TrainControl.csproj
│   ├── bin
│   │   └── Debug
│   │       └── net8.0
│   │           └── linux-arm64
│   └── obj
│       └── Debug
│           └── net8.0
│               └── linux-arm64
│                   ├── TrainControl.GlobalUsings.g.cs
│                   ├── ref
│                   └── refint
└── TrainControl.sln
```
前回の記事で開発したTrainControllerコンソールアプリをセンサーに対応させるため発展させます。
注目する点は`MotorController`クラスに`MonitorSensorAndStop`メソッドを追加したことです。
このメソッドは、線路上の２つのセンサーの値を定期的に監視し、その値が変化（変化率が20%以上）した場合に車両のモーターを停止するための処理を行います。
```/TrainControl/MotorController.cs
using AdcTutorial;
using System.Device.Gpio;
using System.Device.Pwm;
using System.Device.Pwm.Drivers;

namespace TrainControl
{
    public class MotorController : IDisposable
    {
        /// <summary>
        /// モーター制御用のGPIOピン番号 GPIO20
        /// </summary>
        private const int motorRight = 20;
        /// <summary>
        /// モーター制御用のGPIOピン番号 GPIO21
        /// </summary>
        private const int motorLeft = 21;
        /// <summary>
        /// モーター制御用のPWMチャンネル番号 GPIO12
        /// </summary>
        private const int pwmChannel = 12;
        /// <summary>
        /// pwmの周波数
        /// </summary>
        private const int pwmFrequency = 100;
        /// <summary>
        /// 停止時の待機時間
        /// </summary>
        private const int stopDelayMs = 1000;

        private GpioController gpioController;
        private PwmChannel pwmController;
        private bool disposed = false;

        public double MaximumDutyCycle { get; set; } = 0.4;

        private Sensor sensor;
        /// <summary>
        /// 初期化
        /// </summary>
        public MotorController(Sensor sensor)
        {
            this.sensor = sensor;
            gpioController = new GpioController(PinNumberingScheme.Logical);
            try
            {
                pwmController = new SoftwarePwmChannel(pwmChannel, pwmFrequency, 0);
                gpioController.OpenPin(motorRight, PinMode.Output);
                gpioController.OpenPin(motorLeft, PinMode.Output);
                pwmController.Start();
            }
            catch (Exception ex)
            {
                Console.WriteLine($"GPIOピンの初期化でエラーが発生: {ex.Message}");
                throw;
            }
        }
        /// <summary>
        /// 前進
        /// </summary>
        /// <param name="durationMs"></param>
        /// <returns></returns>
        public async Task GoForward(int durationMs)
        {
            await StopAsync();
            Console.WriteLine("前進");
            pwmController.DutyCycle = 0;
            gpioController.Write(motorRight, PinValue.High);
            gpioController.Write(motorLeft, PinValue.Low);
            sensor.EnableSensor(0, true);
            sensor.EnableSensor(1, false);
            await Accelerate();

            await Task.Delay(durationMs);

            await Decelerate();
            await StopAsync();
        }

        /// <summary>
        /// 後退
        /// </summary>
        /// <param name="durationMs"></param>
        /// <returns></returns>
        public async Task GoBackward(int durationMs)
        {
            await StopAsync();
            Console.WriteLine("後退");
            gpioController.Write(motorRight, PinValue.Low);
            gpioController.Write(motorLeft, PinValue.High);
            sensor.EnableSensor(0, false);
            sensor.EnableSensor(1, true);
            await Accelerate();

            await Task.Delay(durationMs);

            await Decelerate();
            await StopAsync();
        }

        /// <summary>
        /// モーターを停止する
        /// </summary>
        /// <returns></returns>
        public async Task StopAsync()
        {
            try
            {
                Console.WriteLine("停止");
                pwmController.DutyCycle = 0;
                gpioController.Write(motorRight, PinValue.High);
                gpioController.Write(motorLeft, PinValue.High);
                await Task.Delay(stopDelayMs);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"停止処理中にエラーが発生: {ex.Message}");
            }
        }

        /// <summary>
        /// モーターを加速させる
        /// </summary>
        /// <returns></returns>
        private async Task Accelerate()
        {
            int steps = (int)(MaximumDutyCycle * 100);

            for (int i = 0; i <= steps; i++)
            {
                pwmController.DutyCycle = (double)i / steps * MaximumDutyCycle;
                await Task.Delay(100);
            }
        }

        /// <summary>
        /// モーターを減速させる
        /// </summary>
        /// <returns></returns>
        private async Task Decelerate()
        {
            int steps = (int)(MaximumDutyCycle * 100);

            for (int i = steps; i >= 0; i--)
            {
                pwmController.DutyCycle = (double)i / steps * MaximumDutyCycle;
                await Task.Delay(100);
            }
        }

        /// <summary>
        /// 線路上のセンサーを監視し、変化があれば停止する
        /// </summary>
        public async Task MonitorSensorAndStop(Sensor sensor, int sensorNumber)
        {
            double previousSensorValue = sensorNumber == 0 ? 
            sensor.GetSensorValues().Item1 : sensor.GetSensorValues().Item2;
            while (true)
            {
                double currentSensorValue = sensorNumber == 0 ?              
                sensor.GetSensorValues().Item1 : sensor.GetSensorValues().Item2;
                double changePercentage = previousSensorValue != 0 ? 
                Math.Abs((currentSensorValue - previousSensorValue) / 
                previousSensorValue * 100) : 0;

                // 変化率が20%以上なら停止
                if (changePercentage >= 20)
                {
                    Console.WriteLine
                    ($"センサー{sensorNumber}の値が変動しました。停止します。");
                    Console.WriteLine
                  ($"Channel {sensorNumber}: {previousSensorValue} -> {currentSensorValue}");
                    await Decelerate();
                    break;
                }

                // 前回のセンサー値を更新
                previousSensorValue = currentSensorValue;

                // 1秒待機
                await Task.Delay(1000);
            }
        }


        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (!disposed)
            {
                if (disposing)
                {
                    pwmController?.Stop();
                    gpioController?.ClosePin(motorRight);
                    gpioController?.ClosePin(motorLeft);
                    gpioController?.Dispose();
                    pwmController?.Dispose();
                }
                disposed = true;
            }
        }
    }
}
```
エントリーポイントの`Program.cs`ではモーターの動作（前進・後退）とセンサー値の監視をTask.WhenAnyメソッド※で非同期に実行し、それらを並行処理しています。これにより、センサー値が変化した場合（センサー上を車両が通過した場合）に車両を停止、走行方向を逆転させる動作を実現しています。

※Task.WhenAnyメソッドはて非同期処理で複数のタスクのうち、最初に完了したタスクを取得するためのメソッドです。
```./TrainControl/Program.cs
using AdcTutorial;
using TrainControl;

Console.WriteLine("Enterキーを押してアプリケーションを起動します。");
Console.ReadLine();

Console.Write("最高速度を入力してください（デューティー比 0.0 ～ 1.0）: ");
double maxDutyCycle;
while (!double.TryParse(Console.ReadLine(), out maxDutyCycle) || maxDutyCycle < 0 || maxDutyCycle > 1)
{
    Console.WriteLine("無効な値です。0.0 ～ 1.0 の間で入力してください。");
}

using (Sensor sensor = new Sensor())
using (MotorController motorController = new MotorController(sensor))
{
    motorController.MaximumDutyCycle = maxDutyCycle;

    Console.WriteLine("走行開始");

    for (int i = 0; i < 5; i++)
    {
        // モーターを前進しながら、センサーを並行して監視する
        var motorTask = motorController.GoForward(5000);  // モーターを前進
        var sensorTask = motorController.MonitorSensorAndStop(sensor: sensor, sensorNumber: 0);  // センサー0を監視して停止

        // 並行して実行されたタスクのどちらかが完了するまで待つ
        var completedTask = await Task.WhenAny(motorTask, sensorTask);

        // センサーの監視タスクが完了した場合にのみ、GoBackwardメソッドを実行
        if (completedTask == sensorTask)
        {
            var motorTask2 = motorController.GoBackward(5000);  // モーターを後退
            var sensorTask2 = motorController.MonitorSensorAndStop(sensor: sensor, sensorNumber: 1);  // センサー1を監視して停止

            // 並行して実行されたタスクのどちらかが完了するまで待つ
            completedTask = await Task.WhenAny(motorTask2, sensorTask2);

            // センサーの監視タスクが完了した場合にのみ、次の前進を実行
            if (completedTask == sensorTask2)
            {
                continue;
            }
        }
    }

    // モーターの前進が終了、もしくはセンサーが検知して停止したら終了
    Console.WriteLine("走行終了 Ctrl+Cキーを押すとプログラムを終了します。");
}

Console.ReadLine();
```
# 8. コンソールアプリの実行
実装が完了したところで、Raspberry Piのコンソールを立ち上げて、アプリを起動します。
アプリを配置したディレクトリに移動します。
```
cd TrainControl
```
下記コマンドで起動します。
```
dotnet ./TrainControl.dll
```
車両がセンサ上を通過したら、自動停止して、車両が反対方向に進むことを確認できました。

さらに、反対方向に進んだ車両がもう一つのセンサ上を通過すると、再び車両の走行方向が反転し、元に戻ってきます。

センサーを組み込むことで、1本の線路で折り返し運転ができるようになり、本物の鉄道に近い運転を実現することができました！
![Image (30).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/44f53df1-0bb8-0883-9698-8c426f4e00a0.jpeg)
![実行結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726580/4afc1e1b-a32f-c870-dfc7-5c05ba0035f4.png)
# 9. 最後に
Raspberry-Piを用いたNゲージ自動運転の内容はいかがだったでしょうか？

センサーを取り入れることで、より複雑な動作を実現することができました。

センサーを活用することで、本物の鉄道と同じようにダイヤに基づいた運転もできそうです。

夢が広がります。

今後もRaspberry-Piを活用しながらIoTスキルを磨きたいです。

最後まで読んでいただきありがとうございました！