RaspberryPiの死活監視システムを Azure IoT Central で構築した

# はじめに
自宅で運用している**RaspberryPi**の死活監視システムを構築したいと思い、GUIを含めたシステムを簡単に構築が出来そうな**Azure IoT Central**を使用してみようと思います。

#####やりたいこと
* リソース情報をダッシュボードに表示
* CPU・メモリ使用率 等のリソース増加の通知
* デバイスのシステムダウン(ラズパイからの通信無し) の通知 

# 環境
* Raspberry Pi 4 Model B
* Python : 3.7.3
* azure-iot-device : 2.7.1 \<pipパッケージ>
* sysstat : 11.5.2 \<debパッケージ>

##### インストール
```
$ pip3 install azure-iot-device
$ sudo apt install sysstat
```

# システム構成 
ラズパイからCPU・メモリ 等のデバイスリソース情報を**Azure IoT Central**に送信して、ダッシュボードにリアルタイムでリソース情報を表示します。また、リソース情報が一定条件を満たすとメールを送信します。

![RaspiSystemMonitor.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1425498/1a7ecab5-02da-60ec-e410-22b256689917.png)

#### ダッシュボード
ラズパイから送信された、CPU使用率、メモリ使用率、ディスク使用率を数値とグラフで表示しています。変動が多いCPUとメモリのみをグラフ表示しています。
数値を表示しているパネルの色は、数値によって変更できるようになっていて、「0～70は青、70~90は黄、90~100は赤」みたいな感じで、ぱっと見で状態が分かるようになっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1425498/5097ba15-6eeb-ee91-6e2d-c90d0a44802b.png)


#### 通知メール
「CPU使用率：90％以上」or「メモリ使用率：85％」の状態を検知すると以下のメールが送られ、デバイスのリソース使用率増加をすぐに知ることが出来ます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1425498/78700a3f-391e-542b-1699-fa593f6c73b3.png)

# ラズパイの設計

### 送信リソース情報
デバイスのリソース情報をjson形式に整形して**Azure IoT Central**に送信します。
※ コア毎のCPU使用率を送信していますが、今回は「All」のみを使用してます。

```json
{
    "CpuUsage": {
        "All": 25.63,
        "Core0": 58.16,
        "Core1": 16.32,
        "Core2": 11.34,
        "Core3": 16.67
    },
    "MemoryUsage": 51.70,
    "DiskUsage": 92,
    "MsgId": 7
}
```

### Pythonプログラム
**Azure IoT Central**の「SCOPE_ID」,「DEVICE_ID」,「DEVICE_KEY」が環境変数に設定されていることが前提条件のプログラムとなっています。`main.py`では通信コネクション及び、取得したリソース情報の定期送信を行っています。

```python:main.py
import os
import sys
import json
import asyncio
from systemMoniter import SystemMoniter
from deviceProvisioningService import Device
from azure.iot.device.aio import IoTHubDeviceClient

def message_received_handler(message):
    # ログ出力のみ
    print("the data in the message received was ")
    print(message.data)
    print("custom properties are")
    print(message.custom_properties)

async def main():
    scopeID = None
    deviceId = None
    key = None

    # 環境変数の取得
    scopeID = os.getenv('SCOPE_ID')
    deviceId = os.getenv('DEVICE_ID')
    key = os.getenv('DEVICE_KEY')
    if scopeID is None or deviceId is None or key is None:
        sys.exit(1)

    # Azureとの通信に必要な「connection string」の取得
    dps = Device(scopeID, deviceId, key)
    conn_str = await dps.connection_string

    # コネクション確立
    device_client = IoTHubDeviceClient.create_from_connection_string(conn_str)
    await device_client.connect()

    # 受信ハンドラの設定、Azureからコマンドを受信するとこの関数が呼び出される
    device_client.on_message_received = message_received_handler

    systemMoniter = SystemMoniter()
    while True:
        try:
            # リソース情報取得
            telemetry = await systemMoniter.getSystemStatus()
            if telemetry is not None:
                # ログ出力、json形式に変換し送信
                print(telemetry)
                data = json.dumps(telemetry)
                await device_client.send_message(data)
                await asyncio.sleep(10)

        except:
            print("Unexpected error:", sys.exc_info()[0])

    # コネクション切断
    await device_client.disconnect()

if __name__ == "__main__":
    asyncio.run(main())
    # If using Python 3.6 or below, use the following code instead of asyncio.run(main()):
    # loop = asyncio.get_event_loop()
    # loop.run_until_complete(main())
    # loop.close()
```

</br>
`deviceProvisioningService.py`では通信に必要な「connection string」の生成を行います。**Azure IoT Central**は内部的には**Azure IoT Hub**を使用しているようで、**Device Provisioning Service**からデバイスに紐づけられたhub名を取得して「connection string」を生成する必要があります。

```python:deviceProvisioningService.py
import asyncio
from azure.iot.device.aio import ProvisioningDeviceClient

class Device():
    def __init__(self, scope, device_id, key):
        self.scope = scope
        self.device_id = device_id
        self.key = key

    async def __register_device(self):
        provisioning_device_client = ProvisioningDeviceClient.create_from_symmetric_key(
            provisioning_host='global.azure-devices-provisioning.net',
            registration_id=self.device_id,
            id_scope=self.scope,
            symmetric_key=self.key,
        )

        return await provisioning_device_client.register()

    @property
    async def connection_string(self):
        results = await asyncio.gather(self.__register_device())
        registration_result = results[0]

        # build the connection string
        conn_str = 'HostName=' + registration_result.registration_state.assigned_hub + \
            ';DeviceId=' + self.device_id + \
            ';SharedAccessKey=' + self.key

        return conn_str
```

</br>
`systemMoniter.py`ではラズパイのリソース情報の取得を行います。LinuxコマンドをPythonから取得し、後からjson型に変換しやすいように`dict`型にデータをまとめています。

```python:systemMoniter.py
import subprocess

class SystemMoniter():
    def __init__(self) -> None:
        self.msgId = 0

    async def getSystemStatus(self) -> dict:
        telemetry = {}
        self.msgId += 1

        telemetry.update(self._cpuUpuUsage())
        telemetry.update(self._memoryUsage())
        telemetry.update(self._diskUsage())
        telemetry["MsgId"] = self.msgId

        return telemetry

    def _cpuUsage(self) -> dict:
        ret = {}
        try:
            usages = {}
            cmd = R"LC_ALL=C sar -P ALL 1 1 | grep Average"
            cpuUsage = subprocess.check_output(cmd, shell=True).decode('utf-8')
            list = cpuUsage.splitlines()[1:]
            for num in range(0, len(list)):
                value = list[num].split()
                if(num == 0): property = "All"
                else:         property = R"Core" + str(num-1)
                # cpuUsage = %user + %nice + %system + %iowait + %steal
                usages[property] = float(value[2])+float(value[3])+float(value[4])+float(value[5])+float(value[6])
            ret["CpuUsage"] = usages
        except:
            print("Failed get the cpu usages.")
            ret = {}
        return ret

    def _memoryUsage(self) -> dict:
        ret = {}
        try:
            cmd = R"free -k | grep Mem:"
            cpuUsage = subprocess.check_output(cmd, shell=True).decode('utf-8')
            list = cpuUsage.split()
            ret["MemoryUsage"] = (float(list[2])*100)/float(list[1])
        except:
            print("Failed get the memory usage.")
            ret = {}
        return ret

    def _diskUsage(self) -> dict:
        ret = {}
        try:
            cmd = R"df | grep /dev/root | awk '{print $5}' | sed 's/%//'"
            diskUsage = subprocess.check_output(cmd, shell=True).decode('utf-8')
            ret["DiskUsage"] = float(diskUsage)
        except:
            print("Failed get the disk usage.")
            ret = {}
        return ret
```


# Azure IoT Central の設計

### デバイステンプレート
ラズパイから送信するjsonデータに合わせてパラメータを設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1425498/5cff9912-6c7e-1afd-c92c-effc2f5349e2.png)

### 規則(アクション)
以下のように条件を設定してメールを送るようにしています。
パラメータの数値によって条件を付けることが出来るが、「受信できなかった」等の条件付けは無く、デバイスからデータが送信されていることが前提の条件付けになっている。その為、やりたかった「**デバイスのシステムダウン(ラズパイからの通信無し) の通知**」は現状できなさそう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1425498/600f69a9-2f3c-1503-c495-af4fa2c47fbf.png)

# 最後に
今回は**Azure IoT Central**を使用して死活監視システムを構築しました。想像していたより簡単に作ることが出来たため、かなり驚いています。これぐらいのシステムなら構想から構築まで1~2日あれば実現できると思います。
気になる点としては、ダッシュボードや規則(アクション)についてはカスタマイズ性がないため、複雑なGUIや規則は設計できませんでした。複雑なことがしたいなら自前で作る必要があります。PoCレベルでさっと作る分ならかなり使えるサービスだと感じました。
やりたかった「**デバイスのシステムダウン(ラズパイからの通信無し) の通知**」が出来なかったのは残念でしたが、今後のアップデートに期待したいと思います。

### 参考
以下の情報を参考にさせていただきました。
[Raspberry-Pi-Python-Environment-Monitor-with-the-Pimoroni-Enviro-Air-Quality-PMS5003-Sensor](https://github.com/gloveboxes/Raspberry-Pi-Python-Environment-Monitor-with-the-Pimoroni-Enviro-Air-Quality-PMS5003-Sensor)