EtherNet/IPでサーモカメラを遠隔操作

## EtherNet/IPとは

産業用の工作機や電子機器は、産業用プロトコルにより他の機器と通信します。
EtherNet/IPは、数ある産業用プロトコルのひとつであり、ODVA(Open DeviceNet Vendor Association, Inc.)によって管理されています。

このプロトコルは、簡単にいうとCIP(Common Industrial Protocol)をTCP/IP上で通信します。
類似のプロトコルとしては、DeviceNetやCompoNet、ControlNetなどがあります。

EtherNet/IPの特徴は、産業界で使われているCIPを、一般に広く普及しているスイッチングハブやイーサネットケーブルで構築したネットワーク上で使える点です。
TCP/IPで通信できればよいため、例えば無線LANやモバイルネットワークでの通信も可能です。

## pycomm3
pycomm3は、PythonでEtherNet/IP通信を可能にするライブラリです。
同じような機能を持つライブラリは複数ありますが、とりあえずGoogle検索して最も上に登場したライブラリを使うことにしました。

以下のコマンドでインストールできます。

```
> pip install pycomm3
```

また、CondaForgeパッケージにも登録されているため、AnacondaでPython環境を構築した方は以下でもインストールできます。

```
> conda install -c conda-forge pycomm3
```

## EtherNet/IP対応サーモカメラ AX8

サーモグラフィカメラ（サーモカメラ）ではFLIR社の製品が有名です。
FLIR社の製品のひとつ、AX8は固定型サーモカメラであり、ネットワーク接続して遠隔でサーモ画像を取得します。

AX8は様々なプロトコルに対応していますが、EtherNet/IPにも対応しています。
今回は、このAX8にEtherNet/IPで接続し、遠隔操作してみます。

## デバイスを見つける

まず、AX8をPCと同じネットワークに接続し、電源をつけておいてください。

そのうえで以下のプログラムを実行すると、ネットワーク上に接続されたEtherNet/IP対応デバイスを列挙し、出力します。

```python
import pycomm3
```
```python
pycomm3.CIPDriver.discover()
```

```:出力例
[{'encap_protocol_version': 1,
  'ip_address': '192.168.0.180',
  'vendor': 'FLIR Systems',
  'product_type': 'Generic Device (keyable)',
  'product_code': 321,
  'revision': {'major': 2, 'minor': 40},
  'status': b'\x00\x00',
  'serial': '********',
  'product_name': 'FLIR AX8',
  'state': 255}]
```

（`serial`の項目は`*`に置き換えています）

ちなみに、すでにIPアドレスが分かっている場合は、list_identityメソッドの引数にIPアドレスを指定して、デバイスの情報を得ることができます。

```python
pycomm3.CIPDriver.list_identity('192.168.0.180')
```

```:出力例（一部抜粋）
{'encap_protocol_version': 1,
 'ip_address': '192.168.0.180',
...
 'state': 255}
```

## デバイスの情報を取得する

ネットワークに接続されたAX8のIPアドレスが分かったところで、AX8の情報を取得してみます。
ここからは、AX8の仕様書とにらめっこしながらプログラミングしていきます。

https://support.flir.com/Answers/A1239/EIP.pdf

EtherNet/IPでは、オブジェクトと呼ばれる、プログラミング言語ではいわゆるクラスが存在します。
クラスには番号があり、例えばIdentity Objectは1番です。
Objectは複数のインスタンスを持ちえます。
Identity Objectはどうやら1インスタンスのみのようです。

クラスは属性(Attribute)を複数持ちます。
これがデータとなります。

例えば、AX8におけるIdentity Objectの定義は以下です。

| Instance | Attirbute ID | Name | Data Type | Data value | Access rule |
|---|---|---|---|--|--|
| Class | 1 | Revision | UINT | 1 | Get |
| Instance 1 | 1 | Vendor number | UINT | 1161 | Get |
| | 2 | Device type | UINT | 43 | Get |
| | 3 | Product code number | UINT | 320 | Get |
| | 4 | Product major revision<br>Product minor revision | USINT<br>USINT | 02 <br>38 | Get |
| | 5 | Status | WORD | Always 0 | Get |
| | 6 | Serial number | UDINT | Unique 32 bit value <br>".version.product.serial" | Get |
| | 7 | Product name | SHORT STRING32 | Depends on camera model. | Get |


これらのオブジェクトとデータのやり取りをするには、サービスを利用します。
オブジェクトごとに利用可能なサービスは異なります。
例えば、Identity Objectで利用可能なサービスは以下です。

| Service code | Class level | Instance level | Service name |
|---|---|---|---|
| 05 | No | Yes | Reset |
| 0E | Yes | Yes | Get_Attribute_Single |

もし、Identity ObjectのProduct nameを取得した場合、以下のようにプログラムすればよいです。

```python
with pycomm3.CIPDriver('192.168.0.180') as driver:
    res = driver.generic_message(
        service=pycomm3.Services.get_attribute_single, # <1>
        class_code=pycomm3.ClassCode.identity_object, # <2>
        instance=1, # <3>
        attribute=7, # <4>
        data_type=pycomm3.SHORT_STRING) # <5>
    print(res)
```

```:出力例
generic, 'FLIR AX8', SHORT_STRING, None
```

プログラムを解説します。

```python
with pycomm3.CIPDriver('192.168.0.180') as driver:
```

により、指定したIPアドレスのデバイスへEtherNet/IPで接続します。
接続したのち、driverでデバイスへの操作が可能です。

```python
res = driver.generic_message(...)
```

generic_messageメソッドにより、接続したデバイスに対して、サービスを実行可能になります。
ここでは、「Get_Attribute_Single」のサービスを実行するため、<1>の引数で指定しています。

続く<2>・<3>で、どのクラス、インスタンスに対してサービスを実行するかを指定します。

取得したい「Product name」は、Attirbute IDが7のため、<4>のように指定しています。

最後、「Product name」のtypeを表で確認すると「SHORT STRING32」なので、`pycomm3.SHORT_STRING`と指定しています。

以上により、1つの属性の値を取得できます。
ちなみに、AX8ではIdentity Objectで利用可能なサービスのうち、データ取得に関するサービスが「Get_Attribute_Single」のみでしたが、
デバイスによっては「Get_Attribute_All」もあります。
この場合、「Get_Attribute_Single」サービスにより、一度で複数の属性をデータ取得できると思います。

## 温度データを取得する

AX8はサーモカメラなので、カメラ視野中の任意の箇所の温度を計測し、EtherNet/IPで出力できます。

仕様書1.3章「1.3 Assembly Object (04HEX - 8 Instances) 」のOutput Dataの中に、「Spot 1 Temperature」があるので、このデータを取得すれば良さそうです。
ただし、そのアドレスは「Byte = 36～39」とあります。
じつは、AX8ではある属性から116バイトのデータを取得してから、そのデータの36～39バイトに、「Spot 1 Temperature」が格納されています。
そのため、自分で必要なデータをパースする必要があります。

以下が、あらかじめ指定した、画像中のある特定の点の温度を取得するプログラムです。
セルシウス温度で出力するよう、絶対温度から変換してあります。
出力例は25.9度で、だいたい室温と同じになりました。

```python
import struct
```

```python
with pycomm3.CIPDriver('192.168.0.180') as driver:
    driver.open()
    res = driver.generic_message(
        service=pycomm3.Services.get_attribute_single,
        class_code=pycomm3.ClassCode.assembly,
        instance=0x64,
        attribute=3,
        )
    buffer = res[1]
    spot1_temp = struct.unpack_from('<f', buffer, 36)[0] - 273.15
    print(f"{spot1_temp:.1f}: spot1 temp")
```

```:出力例
25.9: spot1 temp
```
このプログラムを簡単に解説すると、

```python
res = driver.generic_message(...)
```

により、116バイトのデータを取得できます。
その応答結果のデータの部分のみを変数bufferに格納し、bufferの先頭36バイト目から、32ビット浮動小数をリトルエンディアンで取得します。
取得した浮動小数は絶対温度なので、273.15を減じることで、セルシウス温度となります。

## 撮影する

AX8では「Image File Storage Object (69HEX- 1 Instance) 」というオブジェクトが定義されています。
仕様書の説明を読むと、「Store Image to Camera Memory」の値を1に設定すると、カメラ画像がAX8の内部メモリに画像が保存されるようです。

というわけで、撮影するプログラムは以下のプログラムとなります。
実際に実行したところ、内部に正しく記録されていました。

```python3
with pycomm3.CIPDriver('192.168.0.180') as driver:
    res = driver.generic_message(
        service=0x10, # pycomm3.Services.get_attribute_singleでもよい
        class_code=0x69,
        instance=1,
        attribute=1,
        request_data=bytearray([1]),
        data_type=pycomm3.BOOL
    )
```