解説記事: Modbus RTUを用いたIAI RCONコントローラのPythonプログラミング

こんにちは、今回はPythonを使用してIAI RCONコントローラをModbus RTUで制御する方法について解説します。具体的には、`minimalmodbus`ライブラリを使用して、RCONコントローラに接続し、サーボのオン/オフ、アラームのリセット、原点復帰、ポジション番号への移動を行うプログラムを作成します。また、指定されたポジション番号への移動を繰り返し行い、`q`キーが押されたときにループを停止する機能も追加します。

## コントロールするロボット
![rcp4-main.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208980/21c3f87f-2acb-96bc-ab94-1f49477344f8.jpeg)


## 使用するライブラリ
- `minimalmodbus`: Modbus RTUプロトコルを簡単に扱うためのライブラリです。
- `serial`: シリアル通信を扱うライブラリです。
- `time`: 時間関連の操作を行う標準ライブラリです。
- `keyboard`: キーボード入力を扱うためのライブラリです。

まずは、必要なライブラリをインストールしましょう。

```bash
pip install minimalmodbus pyserial keyboard
```
#### システムのイメージ
今回はTCP/IPではなくSerial接続のケースを解説します。
![rcon.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208980/f547cb48-a895-efae-2c13-95168720e0f3.png)


## プログラムの全体像
以下に、完成したプログラムのコードを示します。各部分について詳細に解説していきます。

```python
import minimalmodbus
import serial
import time
import keyboard

class RCONController:
    def __init__(self, port, slave_address):
        self.port = port
        self.slave_address = slave_address
        self.instrument = None

    def connect(self):
        minimalmodbus.BAUDRATE = 115200
        minimalmodbus.BYTESIZE = 8
        minimalmodbus.STOPBITS = 1
        minimalmodbus.TIMEOUT = 1
        minimalmodbus.PARITY = serial.PARITY_NONE
        minimalmodbus.CLOSE_PORT_AFTER_EACH_CALL = True

        try:
            self.instrument = minimalmodbus.Instrument(self.port, self.slave_address, mode=minimalmodbus.MODE_RTU)
            print(f"Connected to {self.port} with slave address {self.slave_address}")
        except IOError:
            print("Failed to connect to the instrument")

    def check_connection(self):
        if not self.instrument:
            print("Not connected to any instrument")
            return
        try:
            response = self.instrument.read_register(0, 1)  # レジスタアドレス0から1ワード読み取る
            print(f"Response: {response}")
        except IOError:
            print("Failed to communicate with the instrument")

    def reset_alarm(self):
        if not self.instrument:
            print("Not connected to any instrument")
            return
        try:
            register_address = 0x0D00  # レジスタアドレス（16進数表記）
            value_to_write = 0x0001  # 書き込む値（16進数表記）
            self.instrument.write_register(register_address, value_to_write, functioncode=6)
            print("アラームリセットコマンドを送信しました。")

            # 正常リスポンスの受信
            read_value = self.instrument.read_register(register_address)
            print(f'Read Value: {read_value}')
        except IOError:
            print("Failed to send Alarm Reset command")

    def servo_on(self):
        if not self.instrument:
            print("Not connected to any instrument")
            return
        try:
            register_address = 0x0D00  # レジスタアドレス（16進数表記）
            value_to_write = 0x1000  # データ（16進数表記）
            self.instrument.write_register(register_address, value_to_write, functioncode=6)
            print("サーボONコマンドを送信しました。")

            # 現在のサーボ状態の読み取り
            servo_status = self.instrument.read_register(register_address, functioncode=3)
            print(f'Servo Status: {servo_status}')
        except IOError:
            print("Failed to send Servo ON command")

    def home(self):
        if not self.instrument:
            print("Not connected to any instrument")
            return
        try:
            register_address = 0x0D00  # レジスタアドレス（16進数表記）
            value_to_write = 0x1010  # 書き込む値（16進数表記）
            dss1_register = 0x9005  # DSS1レジスタのアドレス（マニュアルに基づく）

            # 原点復帰指令の送信
            self.instrument.write_register(register_address, value_to_write, functioncode=6)
            print("原点復帰コマンドを送信しました。")

            # 原点復帰が完了するまで待機
            while not self.is_home_complete(dss1_register):
                time.sleep(0.1)  # 100ms待機

            print("原点復帰が完了しました。")
        except IOError:
            print("Failed to send Home command")

    def is_home_complete(self, dss1_register):
        try:
            status = self.instrument.read_register(dss1_register, functioncode=3)
            hend_bit = (status & 0x0010) >> 4  # HENDビット（ビット4）を抽出

            print(f"status, {status}")
            print(f"status & 0x0010, {status & 0x0010}")
            print(f"(status & 0x0010) >> 4, {(status & 0x0010) >> 4} ")

            if hend_bit == 1:
                print("原点復帰が完了しました。")
            else:
                print("原点復帰はまだ完了していません。")

            return hend_bit  # HENDビットが1であれば原点復帰完了
        except IOError:
            print("Failed to read DSS1 register")
            return False

    def move_to_position_number(self, position_number):
        if not self.instrument:
            print("Not connected to any instrument")
            return
        try:
            move_command_register = 0x9800  # ポジション番号指定のためのレジスタアドレス
            position_status_register = 0x9014  # 完了ポジション番号を確認するためのレジスタアドレス

            # ポジション番号指定のコマンド送信
            self.instrument.write_register(move_command_register, position_number, functioncode=6)
            print(f"ポジション番号{position_number}への移動コマンドを送信しました。")

            # 移動完了の確認
            def is_move_complete():
                status = self.instrument.read_register(position_status_register, functioncode=3)
                return (status & (1 << (position_number - 1))) != 0

            # 移動が完了するまで待機
            while not is_move_complete():
                time.sleep(0.1)  # 100ms待機

            # 完了ポジション番号を取得
            completed_position = self.instrument.read_register(position_status_register, functioncode=3)
            print(f"完了ポジション番号: {completed_position}")

        except IOError:
            print("Failed to move to the specified position number")

# メイン処理
if __name__ == "__main__":
    rcon = RCONController('COM4', 1)
    rcon.connect()
    rcon.check_connection()
    rcon.reset_alarm()
    rcon.servo_on()
    rcon.home()

    print("Press 'q' to stop the loop.")
    while True:
        rcon.move_to_position_number(2)
        print(f"ポジションNo.2への移動が完了しました。")
        rcon.move_to_position_number(1)
        print(f"ポジションNo.1への移動が完了しました。")

        if keyboard.is_pressed('q'):
            print("Stopping the loop.")
            break
```

### プログラムの詳細解説

#### 1. クラスの初期化
```python
class RCONController:
    def __init__(self, port, slave_address):
        self.port = port
        self.slave_address = slave_address
        self.instrument = None
```
`RCONController`クラスの初期化メソッドでは、シリアルポートとスレーブアドレスを受け取り、Modbus通信を行うためのインストゥルメントを初期化します。

#### 2. 接続メソッド
```python
def connect(self):
    minimalmodbus.BAUDRATE = 115200
    minimalmodbus.BYTESIZE = 8
    minimalmodbus.STOPBITS = 1
    minimalmodbus.TIMEOUT = 1
    minimalmodbus.PARITY = serial.PARITY_NONE
    minimalmodbus.CLOSE_PORT_AFTER_EACH_CALL = True

    try:
        self.instrument = minimalmodbus.Instrument(self.port, self.slave_address, mode=minimalmodbus.MODE_RTU)
        print(f"Connected to {self.port} with slave address {self.slave_address}")
    except IOError:
        print("Failed to connect to the instrument")
```
`connect`

メソッドでは、Modbus通信のパラメータを設定し、指定されたシリアルポートとスレーブアドレスで接続を確立します。接続が成功すると、インストゥルメントが初期化され、成功メッセージが表示されます。

#### 3. 接続確認メソッド
```python
def check_connection(self):
    if not self.instrument:
        print("Not connected to any instrument")
        return
    try:
        response = self.instrument.read_register(0, 1)  # レジスタアドレス0から1ワード読み取る
        print(f"Response: {response}")
    except IOError:
        print("Failed to communicate with the instrument")
```
`check_connection`メソッドでは、指定されたレジスタアドレスからデータを読み取り、接続が正しく確立されているかを確認します。

#### 4. アラームリセットメソッド
```python
def reset_alarm(self):
    if not self.instrument:
        print("Not connected to any instrument")
        return
    try:
        register_address = 0x0D00  # レジスタアドレス（16進数表記）
        value_to_write = 0x0001  # 書き込む値（16進数表記）
        self.instrument.write_register(register_address, value_to_write, functioncode=6)
        print("アラームリセットコマンドを送信しました。")

        # 正常リスポンスの受信
        read_value = self.instrument.read_register(register_address)
        print(f'Read Value: {read_value}')
    except IOError:
        print("Failed to send Alarm Reset command")
```
`reset_alarm`メソッドでは、アラームリセットコマンドを送信し、正常にリセットされたかを確認します。

ちなみに、マニュアルではこのような記載になっています。このマニュアルに準拠して、コードを作成します。

![query-alarm-reset.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208980/88745f02-80a5-67a0-9e26-2f5988cbb010.png)


#### 5. サーボオンメソッド
```python
def servo_on(self):
    if not self.instrument:
        print("Not connected to any instrument")
        return
    try:
        register_address = 0x0D00  # レジスタアドレス（16進数表記）
        value_to_write = 0x1000  # データ（16進数表記）
        self.instrument.write_register(register_address, value_to_write, functioncode=6)
        print("サーボONコマンドを送信しました。")

        # 現在のサーボ状態の読み取り
        servo_status = self.instrument.read_register(register_address, functioncode=3)
        print(f'Servo Status: {servo_status}')
    except IOError:
        print("Failed to send Servo ON command")
```
`servo_on`メソッドでは、サーボをオンにするコマンドを送信し、現在のサーボ状態を確認します。

#### 6. 原点復帰メソッド
```python
def home(self):
    if not self.instrument:
        print("Not connected to any instrument")
        return
    try:
        register_address = 0x0D00  # レジスタアドレス（16進数表記）
        value_to_write = 0x1010  # 書き込む値（16進数表記）
        dss1_register = 0x9005  # DSS1レジスタのアドレス（マニュアルに基づく）

        # 原点復帰指令の送信
        self.instrument.write_register(register_address, value_to_write, functioncode=6)
        print("原点復帰コマンドを送信しました。")

        # 原点復帰が完了するまで待機
        while not self.is_home_complete(dss1_register):
            time.sleep(0.1)  # 100ms待機

        print("原点復帰が完了しました。")
    except IOError:
        print("Failed to send Home command")
```
`home`メソッドでは、原点復帰コマンドを送信し、原点復帰が完了するまで待機します。

#### 7. 原点復帰完了確認メソッド
```python
def is_home_complete(self, dss1_register):
    try:
        status = self.instrument.read_register(dss1_register, functioncode=3)
        hend_bit = (status & 0x0010) >> 4  # HENDビット（ビット4）を抽出

        print(f"status, {status}")
        print(f"status & 0x0010, {status & 0x0010}")
        print(f"(status & 0x0010) >> 4, {(status & 0x0010) >> 4} ")

        if hend_bit == 1:
            print("原点復帰が完了しました。")
        else:
            print("原点復帰はまだ完了していません。")

        return hend_bit  # HENDビットが1であれば原点復帰完了
    except IOError:
        print("Failed to read DSS1 register")
        return False
```
`is_home_complete`メソッドでは、原点復帰が完了したかどうかを確認します。DSS1レジスタのHENDビットをチェックします。

#### 8. ポジション番号への移動メソッド

ポジション番号とは、RCONの内部に、以下のように移動条件を記録することが可能です。
この事例では、ポジション番号１とポジション番号２の間を往復する内容にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/208980/9204d23d-fde4-0aa9-cfa6-54040fdad00f.png)

```python
def move_to_position_number(self, position_number):
    if not self.instrument:
        print("Not connected to any instrument")
        return
    try:
        move_command_register = 0x9800  # ポジション番号指定のためのレジスタアドレス
        position_status_register = 0x9014  # 完了ポジション番号を確認するためのレジスタアドレス

        # ポジション番号指定のコマンド送信
        self.instrument.write_register(move_command_register, position_number, functioncode=6)
        print(f"ポジション番号{position_number}への移動コマンドを送信しました。")

        # 移動完了の確認
        def is_move_complete():
            status = self.instrument.read_register(position_status_register, functioncode=3)
            return (status & (1 << (position_number - 1))) != 0

        # 移動が完了するまで待機
        while not is_move_complete():
            time.sleep(0.1)  # 100ms待機

        # 完了ポジション番号を取得
        completed_position = self.instrument.read_register(position_status_register, functioncode=3)
        print(f"完了ポジション番号: {completed_position}")

    except IOError:
        print("Failed to move to the specified position number")
```
`move_to_position_number`メソッドでは、指定されたポジション番号に移動するコマンドを送信し、移動が完了するまで待機します。

#### 9. メイン処理
```python
if __name__ == "__main__":
    rcon = RCONController('COM4', 1)
    rcon.connect()
    rcon.check_connection()
    rcon.reset_alarm()
    rcon.servo_on()
    rcon.home()

    print("Press 'q' to stop the loop.")
    while True:
        rcon.move_to_position_number(2)
        print(f"ポジションNo.2への移動が完了しました。")
        rcon.move_to_position_number(1)
        print(f"ポジションNo.1への移動が完了しました。")

        if keyboard.is_pressed('q'):
            print("Stopping the loop.")
            break
```
メイン処理では、RCONコントローラに接続し、アラームをリセット、サーボをオン、原点復帰を行います。その後、ポジション番号2と1への移動を繰り返し、`q`キーが押されたときにループを停止します。

### 結論
このプログラムは、IAI RCONコントローラをModbus RTUを使用して制御するための基本的な操作を実装しています。これを基に、さらに複雑な制御システムを構築することができます。Pythonと`minimalmodbus`ライブラリを使用することで、Modbus通信を簡単に扱うことができ、工業用デバイスの制御に役立つツールとなるでしょう。

### 注意事項
- 実行前に、接続するデバイスのシリアルポートやスレーブアドレスを正しく設定してください。
- 実行中に予期しない動作が発生した場合は、すぐに停止してください。

### 参考資料
- [minimalmodbus ドキュメント](https://minimalmodbus.readthedocs.io/en/stable/)
- IAI Modbus Manual
- IAI RCon Manual