ラズパイで部屋の環境(温度湿度気圧CO2)をモニタリングするIoTデバイスを作る② ～AWS IoT Coreへのデータ送信～

## はじめに
この記事は下記記事の続きとなっていますので先にこちらをご覧ください。

https://qiita.com/h0uk1st4r/items/358749543e06c4553615

本記事ではIoTデバイスたらしめるためにAWS IoT Coreに接続してデータを送信できるようにします。

## AWS IoT Coreでモノを作成する
AWS IoT Coreにラズパイを接続するためにはIoT Coreで事前にモノ(Thing)を作成する必要があります。
※AWSアカウントの作成方法等については触れませんので他の記事をご覧ください。
### ポリシーの作成
モノをIoT Coreに接続する際、IoT Coreポリシーによって接続を制限することができます。
今回はゆるゆるですが次のようなポリシーを割り当てました。
```json:ポリシー
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "iot:Connect",
        "iot:Subscribe",
        "iot:Publish",
        "iot:Receive"
      ],
      "Resource": "*"
    }
  ]
}
```

### モノの作成
AWS IoTのマネジメントコンソールからモノを作成します。
1. 「AWS IoT > 管理 > モノ」を開き「モノを作成」を選択
![006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/23dd583b-d31a-dd04-0353-9c10b1cc782e.png)

1. 「1つのモノを作成」を選択し「次へ」
![007.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/846ac0d0-97a6-2745-d3aa-8bef98418487.png)

1. 「モノの名前」を入力し、Device Shadowに「名前のないシャドウ(クラシック)」を選択し「次へ」
    今回は「iot-env-data-collector」という名前にしました。
![008.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/2b58c0a5-632d-d9e4-72a1-bda09680f692.png)

1. 「新しい証明書を自動生成(推奨)」を選択肢「次へ」
![009.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/4fb30458-649a-4e41-731e-9ccc3efcf181.png)

1. 証明書に作成しておいたポリシー「iot-thing-policy」をアタッチし「次へ」
![010.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/63234649-df07-77b3-e216-c122b27b0fc3.png)

1. 証明書とキーをダウンロードではすべてダウンロードして「完了」
    
    :::note warn
    注釈にあるようにキーファイルはこのタイミングでしかダウンロードできません
    忘れずにすべてダウンロードしておきましょう
        :::

    ![011_mask.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/866f98cd-6090-baa2-1892-166fe916716e.png)

1. 作成したモノの詳細が確認できれば、作成完了です
![012_mask.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/250bdb5a-ff88-00f7-7303-0a724a7396f6.png)

## AWS IoT Device SDK for Pythonのインストール
ラズパイをAWS IoTCoreに接続するために必要なSDKをインストールします。
1. 必要なライブラリのインストール
    SDKに必要なライブラリをインストールします。
    ```shell-session
    $ sudo apt install cmake
    ～省略～
    この操作後に追加で 37.2 MB のディスク容量が消費されます。
    続行しますか? [Y/n] Y
    ～省略～
    $
    $ sudo apt install libssl-dev
    ～省略～
    この操作後に追加で 13.1 MB のディスク容量が消費されます。
    続行しますか? [Y/n] Y
    ～省略～
    $
    ```

    Gitは既に入っていました。
    ```shell-session
    $ git --version
    git version 2.39.5
    $
    ```

    未インストールの場合はインストールが必要です。
    ```shell-session
    $ sudo apt install git
    ```

1. SDKリポジトリのクローン
    AWS IoT Device SDK for Pythonのリポジトリをクローンします。
    ```shell-session
    $ git clone https://github.com/aws/aws-iot-device-sdk-python-v2.git
    Cloning into 'aws-iot-device-sdk-python-v2'...
    ～省略～
    Resolving deltas: 100% (1755/1755), done.
    $
    ```
    
1. SDKのインストール
    仮想環境にAWS IoT Device SDK for Pythonをインストールします。
    ```shell-session
    $ source  bin/activate
    (EnvDataCollector) $
    (EnvDataCollector) $ pip install ./aws-iot-device-sdk-python-v2
    ～省略～
    Successfully installed awscrt-0.21.1 awsiotsdk-1.0.0.dev0
    (EnvDataCollector) $
    ```

## AWS IoT Coreへの接続のテスト
ラズパイがAWS IoT Coreに接続でき、メッセージをPubSubできるかをテストします。
### 証明書の転送
モノの作成時にダウンロードした証明書をzip化してラズパイに転送します。
※WindowsPCからラズパイにSCPでcerts.zipファイルを転送しました。
```shell
>tar -tf certs.zip
certs/AmazonRootCA1.pem
certs/AmazonRootCA3.pem
certs/certificate.pem.crt
certs/private.pem.key
certs/public.pem.key

>
>scp certs.zip <ユーザ名>@<ラズパイのIPアドレス>:/home/<ユーザ名>/Program/EnvDataCollector
<ユーザ名>@<ラズパイのIPアドレス>'s password:
certs.zip                                                                             100% 4600   641.7KB/s   00:00

>
```

ラズパイ側ではcerts.zipを解凍しておきます。
```shell-session
$ ls -l | grep certs
-rw-r--r--  1 <ユーザ名> <ユーザグループ名> 4600 11月 17 16:49 certs.zip
$
$ unzip certs.zip
Archive:  certs.zip
  inflating: certs/AmazonRootCA1.pem
  inflating: certs/AmazonRootCA3.pem
  inflating: certs/certificate.pem.crt
  inflating: certs/private.pem.key
  inflating: certs/public.pem.key
$
$ ls -l | grep certs
drwxr-xr-x  2 <ユーザ名> <ユーザグループ名> 4096 11月 17 16:50 certs
-rw-r--r--  1 <ユーザ名> <ユーザグループ名> 4600 11月 17 16:49 certs.zip
$
```
### 接続テスト
サンプルプログラムを実行し、AWS IoT Coreへの接続テストを実施します。
1. 「AWS IoT > MQTT テストクライアント」を開き「エンドポイント」のFQDNを確認します![020_mask.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/9741267a-85dd-82a8-4d53-884f46df2602.png)

1. クローンしたSDKに同梱されているサンプルプログラムを実行します
    オプション「--endpoint」には前項で確認したエンドポイントのFQDNを入れます。
    ```shell-session
    $ source  bin/activate
    (EnvDataCollector) $ python aws-iot-device-sdk-python-v2/samples/pubsub.py --client_id iot-env-data-collector --topic topic_1 --ca_file certs/AmazonRootCA1.pem --cert certs/certificate.pem.crt --key certs/private.pem.key --endpoint aXXXXXXXXXXXX8-ats.iot.ap-northeast-1.amazonaws.com
    ```
1. 各種設定の問題がなければメッセージをPubSubできます
    ```shell-session
    Connecting to aXXXXXXXXXXXX8-ats.iot.ap-northeast-1.amazonaws.com with client ID 'iot-env-data-collector'...
    Connection Successful with return code: 0 session present: True
    Connected!
    Subscribing to topic 'topic_1'...
    Subscribed with 1
    Sending 10 message(s)
    Publishing message to topic 'topic_1': Hello World!  [1]
    Received message from topic 'topic_1': b'"Hello World!  [1]"'
    Publishing message to topic 'topic_1': Hello World!  [2]
    Received message from topic 'topic_1': b'"Hello World!  [2]"'
    Publishing message to topic 'topic_1': Hello World!  [3]
    Received message from topic 'topic_1': b'"Hello World!  [3]"'
    Publishing message to topic 'topic_1': Hello World!  [4]
    Received message from topic 'topic_1': b'"Hello World!  [4]"'
    Publishing message to topic 'topic_1': Hello World!  [5]
    Received message from topic 'topic_1': b'"Hello World!  [5]"'
    Publishing message to topic 'topic_1': Hello World!  [6]
    Received message from topic 'topic_1': b'"Hello World!  [6]"'
    Publishing message to topic 'topic_1': Hello World!  [7]
    Received message from topic 'topic_1': b'"Hello World!  [7]"'
    Publishing message to topic 'topic_1': Hello World!  [8]
    Received message from topic 'topic_1': b'"Hello World!  [8]"'
    Publishing message to topic 'topic_1': Hello World!  [9]
    Received message from topic 'topic_1': b'"Hello World!  [9]"'
    Publishing message to topic 'topic_1': Hello World!  [10]
    Received message from topic 'topic_1': b'"Hello World!  [10]"'
    10 message(s) received.
    Disconnecting...
    Connection closed
    Disconnected!
    (EnvDataCollector) $
    ```

    またモノの「アクティビティ」タブで接続やサブスクライブ、切断済みなどのアクティビティを確認することができます。
    ![021_mask.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/f30b554e-21b2-56c9-15fd-ba82af6cbbcf.png)

### クローンしたフォルダの削除
SDK自体は仮想環境にインストールしているので上記の接続テストに問題がなければクローンしたフォルダは不要になります。
そのためクローンしたフォルダは削除してしまいます。
```shell-session
$ rm -rf aws-iot-device-sdk-python-v2
```

## IoT Coreに接続してセンサーデータを送信する
次のコードでIoT Coreに接続し名前のないシャドウにセンサーデータを60秒おきに送信します。
```python:main.py
import os
import sys
import time
from typing import Literal
from concurrent.futures import Future
from logging import Logger, getLogger, StreamHandler, DEBUG, INFO, WARNING

import mh_z19
from cgsensor import BME280
from awscrt.mqtt import QoS, Connection
from awsiot import mqtt_connection_builder
from awsiot.iotshadow import ShadowState, IotShadowClient, UpdateShadowRequest, UpdateShadowResponse

class EnvDataCollector:
    LOGGING_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = os.environ.get("LOGGING_LEVEL", "WARNING")

    ENDPOINT: str = "aXXXXXXXXXXXX8-ats.iot.ap-northeast-1.amazonaws.com"
    CLIENT_ID: str = "iot-env-data-collector"
    KEEP_ALIVE_SECS: int = 90
    CERT_FILEPATH: str = "certs/certificate.pem.crt"
    PRI_KEY_FILEPATH: str = "certs/private.pem.key"
    CA_FILEPATH: str = "certs/AmazonRootCA1.pem"

    def __init__(self) -> None:
        self._create_logger()
        self._bme280: BME280 = BME280()
        self._mqtt_connection: Connection = mqtt_connection_builder.mtls_from_path(
            endpoint=self.ENDPOINT,
            cert_filepath=self.CERT_FILEPATH,
            pri_key_filepath=self.PRI_KEY_FILEPATH,
            ca_filepath=self.CA_FILEPATH,
            client_id=self.CLIENT_ID,
            keep_alive_secs=self.KEEP_ALIVE_SECS
        )
        self._shadow_client: IotShadowClient = IotShadowClient(self._mqtt_connection)

    def _create_logger(self) -> None:
        self._logger: Logger = getLogger(self.__class__.__name__)
        self._logger.setLevel(self.LOGGING_LEVEL)

        stdout_handler: StreamHandler = StreamHandler(stream=sys.stdout)
        stdout_handler.setLevel(DEBUG)
        stdout_handler.addFilter(lambda record: record.levelno <= INFO)

        stderr_handler: StreamHandler = StreamHandler(stream=sys.stderr)
        stderr_handler.setLevel(WARNING)

        self._logger.addHandler(stdout_handler)
        self._logger.addHandler(stderr_handler)

    def _connect_iot_core(self) -> bool:
        connect_future: Future[Connection] = self._mqtt_connection.connect()
        try:
            connect_future.result()
        except Exception as e:
            self._logger.error(f"Cannot connect iot core, reason:\n{e}")
            return False
        return True

    def _get_data_from_mh_z19c(self) -> int:
        return mh_z19.read()["co2"]

    def _get_data_from_bme280(self) -> tuple[float, float, float]:
        self._bme280.forced()
        return self._bme280.temperature, self._bme280.humidity, self._bme280.pressure

    def _report_env_data(self) -> bool:
        temperature: float
        humidity: float
        pressure: float
        try:
            temperature, humidity, pressure = self._get_data_from_bme280()
        except Exception as e:
            self._logger.error(f"Cannot get data from BME280, reason:\n{e}")
            return False

        try:
            concentration: int = self._get_data_from_mh_z19c()
        except Exception as e:
            self._logger.error(f"Cannot get data from MH-Z19C, reason:\n{e}")
            return False

        self._logger.debug(f"{temperature=}")
        self._logger.debug(f"{humidity=}")
        self._logger.debug(f"{pressure=}")
        self._logger.debug(f"{concentration=}")

        update_request: UpdateShadowRequest = UpdateShadowRequest(
            thing_name=self.CLIENT_ID,
            state=ShadowState(
                reported={
                    "connected": True,
                    "temperature": temperature,
                    "humidity": humidity,
                    "pressure": pressure,
                    "concentration": concentration
                }
            )
        )

        update_future: Future[UpdateShadowResponse] = self._shadow_client.publish_update_shadow(
            request=update_request,
            qos=QoS.AT_LEAST_ONCE
        )
        try:
            update_future.result()
        except Exception as e:
            self._logger.error(f"Cannot report device shadow, reason:\n{e}")
            return False
        return True

    def execute(self) -> None:
        if not self._connect_iot_core():
            self._logger.critical("Cannot connect iot core")
            return

        while True:
            if not self._report_env_data():
                self._logger.critical("Cannot report env data")
                return
            time.sleep(60)

if __name__ == "__main__":
    EnvDataCollector().execute()

```

環境変数「LOGGING_LEVEL」にDEBUGを与え実行すると60秒ごとに各種センサーデータが表示されます。
```shell-session
$ sudo LOGGING_LEVEL=DEBUG bin/python main.py
temperature=24.9
humidity=40.9
pressure=1021.5
concentration=1527
temperature=24.9
humidity=41.0
pressure=1021.4
concentration=1551
```

正常に接続とデータ送信ができれば、マネコン上でDevice Shadowのreportedにラズパイから送信したデータを確認することができます。
![001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/23c61608-787d-a5b2-e7bc-c6615e6c3734.png)

※Device Shadowの不要なキーは値に`null`を設定し更新することで消すことができます
![002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/eb85e85e-8395-5564-1123-68cbbd3df305.png)

## PM2でPythonスクリプトを永続化する
部屋の環境をモニタリングするという性質上、作成したPythonスクリプトに予期せぬエラーが発生した場合やラズパイ自体を再起動した場合でもデータ送信し続けて欲しいです。
PM2というプロセス管理アプリケーションを使用することで上記を実現することができるので、これを利用してPythonスクリプトを永続化します。
1. Node.jsのインストール
    PM2はNode.jsのアプリケーションであるため、Node.jsをインストールします。
    ```shell-session
    $ sudo apt install nodejs npm
    ～省略～
    この操作後に追加で 162 MB のディスク容量が消費されます。
    続行しますか? [Y/n] Y
    ～省略～
    $
    ```
1. PM2のインストール
    PM2をインストールします。
    ```shell-session
    $ sudo npm install -g pm2
    
    added 138 packages in 30s
    
    13 packages are looking for funding
      run `npm fund` for details
    $ sudo pm2 --version
    ～省略～
    5.4.3
    $
    ```

    PM2は実行しているプログラムの標準出力と標準エラーをログファイルに保存します。
    長期間実行するため、ログローテートできるように`pm2-logrotate`を合わせてインストールしておきます。
    ```shell-session
    $ sudo pm2 install pm2-logrotate
    ～省略～
    Module
    ┌────┬────────────────────┬──────────┬──────────┬──────────┐
    │ id │ name               │ status   │ cpu      │ mem      │
    ├────┼────────────────────┼──────────┼──────────┼──────────┤
    │ 0  │ pm2-logrotate      │ online   │ 0%       │ 33.4mb   │
    └────┴────────────────────┴──────────┴──────────┴──────────┘
    $
    ```
1. PM2でPythonスクリプトを永続化
    `pm2 start`コマンドでPythonスクリプトをPM2に登録し永続化します。
    ```shell-session
    $ sudo pm2 start main.py --name EnvDataCollector --interpreter bin/python
    [PM2] Starting /home/<ユーザ名>/Program/EnvDataCollector/main.py in fork_mode (1 instance)
    [PM2] Done.
    ┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐
    │ id │ name               │ mode     │ ↺    │ status    │ cpu      │ memory   │
    ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤
    │ 1  │ EnvDataCollector   │ fork     │ 0    │ online    │ 0%       │ 6.5mb    │
    └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘
    Module
    ┌────┬────────────────────┬──────────┬──────────┬──────────┐
    │ id │ name               │ status   │ cpu      │ mem      │
    ├────┼────────────────────┼──────────┼──────────┼──────────┤
    │ 0  │ pm2-logrotate      │ online   │ 0%       │ 63.3mb   │
    └────┴────────────────────┴──────────┴──────────┴──────────┘
    $
    ```

    上記は一時的な設定のため`pm2 save`コマンドで設定を保存します。
    ```shell-session
    $ sudo pm2 save
    [PM2] Saving current process list...
    [PM2] Successfully saved in /root/.pm2/dump.pm2
    $
    ```

    Pythonスクリプト自体の永続化は完了したため、マネコン上で60秒ごとにシャドウの更新ができていることが確認できると思います。
    ![003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/48929139-430a-d6f9-291f-db0c7f4c9443.png)

    
1. PM2のsystemdへの登録
    Pythonスクリプト自体をPM2で永続化することはできましたが、ラズパイ再起動後もPM2が動作できるよう`pm2 startup`コマンドでsystemdへ登録します。
    ```shell-session
     $ sudo pm2 startup
    [PM2] Init System found: systemd
    Platform systemd
    ～省略～
    [PM2] [-] Executing: systemctl enable pm2-root...
    Created symlink /etc/systemd/system/multi-user.target.wants/pm2-root.service → /etc/systemd/system/pm2-root.service.
    ～省略～
    ```

    `systemctl`でサービスを起動します。
    ```shell-session
    $ sudo systemctl start pm2-root
    $ sudo systemctl status pm2-root
    ● pm2-root.service - PM2 process manager
         Loaded: loaded (/etc/systemd/system/pm2-root.service; enabled; preset: enabled)
         Active: active (running) since Mon 2024-11-18 23:59:34 JST; 3s ago
           Docs: https://pm2.keymetrics.io/
        Process: 10078 ExecStart=/usr/local/lib/node_modules/pm2/bin/pm2 resurrect (code=exited, status=0/SUCCESS)
       Main PID: 9459 (PM2 v5.4.3: God)
          Tasks: 0 (limit: 755)
            CPU: 2.406s
         CGroup: /system.slice/pm2-root.service
                 ‣ 9459 "PM2 v5.4.3: God Daemon (/root/.pm2)"
    ```
1. 動作確認
    ラズパイを再起動し、マネコン上で再起動後もシャドウの更新が行われていることが確認できれば完了です。
    ```shell-session
    $ sudo reboot
    ```
    ![004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/a5948c94-4150-5181-a8f6-155990b02f7a.png)

## さいごに
センサーから取得したデータをAWS IoT Coreに送信することができるようになりました。
これでIoTデバイスと名乗れるようにはなりましたがデバイスシャドウで確認できるのは直近のデータのみとなっており、過去のデータも見れるようしたいところです。

次の記事ではAWS IoT Coreに送信されたデータを時系列データとして蓄えることができるようにしていきます。

https://qiita.com/h0uk1st4r/items/5f206e64744a3724f5ef