IoT Eventsで死活監視を実装してみる

#はじめに
AWS IoT EventsはIoTデバイス等のイベントを検出、対応できるマネージド型のIoTサービスです。
GUIを使って容易にイベント検出・アクションの定義を作成できるのが魅力だと思います。
参考：[【AWS Black Belt Online Seminar】AWS IoT Events](https://www.youtube.com/watch?v=nZ56k0CPp4Q)

今回はIoT Eventsを使ってデバイスの死活監視を作成します。

#1 今回の構成
今回は以下の機能をIoT Eventsで実装します。

* デバイス単位で監視する
* 一定時間デバイスからペイロードが送られて来ない場合にアラートを通知する
* 上記「一定時間」はユーザーが自由に設定できるようにする（分単位）
* 死活監視の対象から外せるようにする

構成図は以下の通りです。
![構成図_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/437babfa-437e-11ac-b9ca-4bb8ae439108.png)

#2 アラート通知箇所の作成
死活監視で異常があった際にメールを通知する箇所を先に作成します。
IoT Eventsで探知機モデルを作成する際にアクションとして指定するので、先に用意します。

##2-1 Amazon SESの準備
今回はAmazon SESを利用してメール通知を行います。
簡単な検証のみなので、送信元・送信先共に自身のメールアドレスを利用します。

1.Amazon SESのマネジメントコンソールにアクセスし、「Create Identity」をクリックする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/bd56aff6-bf9d-ac02-6a25-4e74529e7f26.png)
2.以下の通り選択、入力して、画面下部の「Create Identity」をクリックする

* Identity Type：Email Address
* Email address：※今回利用するメールアドレス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/6d050174-5b2a-e6e7-664e-f98e528df6f5.png)

3.AWSから「Amazon Web Services – Email Address Verification Request in region {リージョン名}」という件名のVerify用のメールが届くので、リンクをクリックする
※リンク先はAmazon SESの紹介ページになっている
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/70639657-d32e-a904-6de8-fdf1bde32369.png)

4.Amazon SESのマネジメントコンソール上で先程追加したメールアドレスが「Verified」となっていることを確認する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/e3eb0129-58e2-7b24-64c7-cd9b01cdaded.png)

なお、SESは利用開始時点ではSandBoxという扱いになります。
AWSにSandBoxから外へ移動するようリクエストするまでは、送信先として指定できるのはVerifyしたメールアドレスになるなど制限がかかります。
参考：[Amazon SES サンドボックス外への移動](https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/request-production-access.html)

##2-2 Lambdaの作成
探知機モデルでアラートを検知した際にメール通知を行うLambdaを作成します。

1.以下の通りLambda関数を作成する

* 一から作成
* 関数名：※任意の関数名
* ランタイム：Python 3.8
* アーキテクチャ：x86_64

2.File > New File の順にクリックし、メッセージ用のテキスト「message.txt」を追加する

```text
{0}が最後にデータを通知してから{1}分が経過しました。
デバイスが接続されていない可能性があります。

最終通信時刻：{2}
```

3.今回は以下の通りコードを記述する

```python:lambda_function.py
import boto3
import datetime

#client設定
ses = boto3.client("ses")

#変数設定（今回は送信元/送信先共に同じ）
src_mail = "xxx@test.jp"
dst_mail = "xxx@test.jp"

def lambda_handler(payload, context):
    
    #探知機モデルから受け取ったペイロードより変数設定
    device_id = payload["deviceId"]
    timestamp = payload["timestamp"]
    alert_minutes = payload["alertMinutes"]
    
    #timestampを現在の時刻（JST）に変換し、通知用のJST時刻（YYYY-MM-DD HH:MM:SS形式）にする
    format_timestamp = datetime.datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%SZ")
    jst_timestamp = datetime.datetime.strftime(format_timestamp + datetime.timedelta(hours=9), "%Y-%m-%d %H:%M:%S")
    
    #メールメッセージ本文を作成する
    subject = "{0}が接続されていません".format(device_id)
    with open("message.txt", "r") as f:
        text = f.read()
        mail_msg = text.format(device_id, alert_minutes, jst_timestamp)
    
    #メールを送信する
    ses.send_email(
        Source = src_mail,
        Destination={
            'ToAddresses': [dst_mail]
        },
        Message={
            'Subject': {
                'Data': subject,
            },
            'Body': {
                'Text': {
                    'Data': mail_msg,
                },
            }
        }
    )

```

4.Lambdaの設定 > アクセス権限 > ロール名 の順にクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/4e9d60d5-bab6-8a24-c664-a10ae8e17608.png)

5.「ポリシーのアタッチ」をクリック
6.（今回は簡易に実装するのみなので）AmazonSESFullAccessをチェックして「ポリシーのアタッチ」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/752a465f-b800-1510-71d2-c9925d2acba5.png)

#3 探知機モデルの作成
実際に死活監視を行う探知機モデルを作成します。

##3-1 入力の作成
IoT Eventsでは「入力」を作成し、そこで受け取るペイロードの形式を定義します。
IoT RuleのアクションやLambdaからペイロードを受け取る際は、ここで作成する「入力」を指定します。

1.ローカルで以下のJSONファイルを作成する

```json
{
    "deviceId": "",
    "timestamp": "",
    "alertMinutes": "",
    "excluded": ""
}
```
2.IoT Eventsのマネジメントコンソールで左メニューより「入力」をクリック
3.「入力の作成」をクリック
4.「ファイルをアップロード」をクリックして、手順1で作成したJSONファイルを選択する
5.以下の通り入力、選択して「作成」をクリック

* 入力名：DetectionInput
* 入力属性の選択：deviceId, timestamp を選択

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/35da6212-3f48-9ea8-d33a-e94ebeaa766b.png)

6.同様にして、以下の「入力」を作成する

* 入力名：AlertMinutesInput
* 入力属性の選択：deviceId, alertMinutes, excluded を選択

##3-2 探知機モデルの作成①：「状態」の用意
いよいよ、実際に検知・アクションを行う探知機モデルを作成します。
ここでは、「状態」の用意まで行います。

1.IoT Eventsのマネジメントコンソールで「探知機モデルの作成」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/d4117b0f-f61f-6940-9487-fb666bb295b3.png)

2.今回は「新しく作成する」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/ad432077-6724-fdde-b776-1f0d2f1296be.png)

3.探知機モデルパレットの「状態」をドラッグアンドドロップして2つ追加する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/ec0614b1-d825-ef44-94cb-437aa4c8f2df.png)

4.それぞれの状態の名前を以下の通り変更する

* untitled_state_1 -> Normal
* State_1 -> Excluded
* untitled_state_2 -> Alert

5.以下の通り遷移するようにし、遷移名は「To + {遷移先の状態名}」とする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/84f32601-4943-68e5-5fc6-4865944da089.png)

##3-3 探知機モデルの作成②：Excludedのロジック作成
3-2で作成した「状態」のうち、Excludedにロジックを追加していきます。
ここでは、excludedをfalseに設定されたらNormalに移行するようにします。
具体的には以下のロジックを追加します。

* AlertMinutesInputに入力があった際に、変数alert_minutesをセットする
  * ペイロードのexcludedがfalseならば、状態Normalに遷移する

1.OnInputの「イベントの追加」をクリックし、以下の通りイベントを作成する

* イベント名：set_variables
* イベントの条件：`currentInput("AlertMinutesInput") `
* イベントアクション：変数の設定
  * 変数オペレーション：値の割り当て
  * 変数名：alert_minutes
  * 値の割り当て：`$input.AlertMinutesInput.alertMinutes`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/b2fd7fd1-8dff-34cc-433d-d5f861aafd84.png)

2.移行ToNormalのイベントのトリガーロジックを以下の通り入力する

```text:イベントのトリガーロジック
$input.AlertMinutesInput.excluded == false
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/88fd5277-117d-a86f-e268-ab0749b749ce.png)

##3-4 探知機モデルの作成②：Normalのロジック作成
3-2で作成した「状態」のうち、Normalにロジックを追加していきます。
具体的には以下のロジックを追加します。

* AlertMinutesInputに入力があった際に、変数alert_minutesをセットする
  * ペイロードのexcludedがtrueならば、状態Excludedに遷移する
* DetectionInputに入力があった際に、タイマーAlertTimerを変数alert_minutesに設定された時間にリセットする
* タイマーAlertTimerがタイムアウトしたらAlertに遷移する

1.OnInputの「イベントの追加」をクリックし、以下の通りイベントを作成する

* イベント名：set_variables
* イベントの条件：`currentInput("AlertMinutesInput") `
* イベントアクション：変数の設定
  * 変数オペレーション：値の割り当て
  * 変数名：alert_minutes
  * 値の割り当て：`$input.AlertMinutesInput.alertMinutes`

2.続けてOnInputの「イベントの追加」をクリックし、以下の通りイベントを作成する

* イベント名：update_timer
* イベントの条件：`currentInput("DetectionInput") `
* イベントアクション：タイマーの設定
  * オペレーションの選択：作成
  * タイマー名：AlertTimer
  * 式を入力：`$variable.alert_minutes * 60`

3.移行ToExcludedのイベントのトリガーロジックを以下の通り入力する

```text:イベントのトリガーロジック
$input.AlertMinutesInput.excluded == true
```

4.移行ToAlertのイベントのトリガーロジックを以下の通り入力する

```text:イベントのトリガーロジック
timeout("AlertTimer")
```

##3-5 探知機モデルの作成③：Alertのロジック作成
3-2で作成した「状態」のうち、Alertにロジックを追加していきます。
具体的には以下のロジックを追加します。

* Alertに遷移したタイミングで2-2で作成したLambdaを実行する
* AlertMinutesInputに入力があった際に、変数alert_minutesをセットする
  * ペイロードのexcludedがtrueならば、状態Excludedに遷移する
* DetectionInputに入力があった際に、状態Normalに遷移する
  * タイマーAlertTimerを変数alert_minutesに設定された時間でセットする

1.OnEnterの「イベントの追加」をクリックし、以下の通りイベントを作成する

* イベント名：send_alert
* イベントの条件：`true`
* イベントアクション：Lambda
  * 関数のARN：※2-2で作成したLambdaのARN
  * カスタムペイロード：JSON　※ペイロードの中身は次の通り

```json:カスタムペイロード
'{
  "deviceId": "${$input.DetectionInput.deviceId}",
  "timestamp": "${$input.DetectionInput.timestamp}",
  "alertMinutes": "${$variable.alert_minutes}"
}'
```

2.OnInputの「イベントの追加」をクリックし、以下の通りイベントを作成する

* イベント名：set_variables
* イベントの条件：`currentInput("AlertMinutesInput") `
* イベントアクション：変数の設定
  * 変数オペレーション：値の割り当て
  * 変数名：alert_minutes
  * 値の割り当て：`$input.AlertMinutesInput.alertMinutes`

3.移行ToExcludedのイベントのトリガーロジックを以下の通り入力する

```text:イベントのトリガーロジック
$input.AlertMinutesInput.excluded == true
```

4.移行ToNormalのイベントのトリガーロジックを以下の通り入力する

```text:イベントのトリガーロジック
currentInput("DetectionInput")
```

5.移行ToNormalに以下のアクションを追加する

* イベントアクション：タイマーの設定
  * オペレーションの選択：作成
  * タイマー名：AlertTimer
  * 式を入力：`$variable.alert_minutes * 60`

##3-6 探知機モデルの発行
ロジックをすべて追加したので、探知機モデルを発行します。

1.画面左上の「編集」をクリックする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/bfe48243-ce91-87a4-e351-dd329f9ae41d.png)

2.以下の通り入力、選択して「保存」をクリック

* ディテクターモデル名：※任意の名称
* ロール：※任意の新しいロール名
* 探知機生成メソッド：一意のキー値ごとに探知機を作成する
* 探知機作成キー：deviceId
* ディテクターの評価方法：シリアル評価

3.画面右上の「分析を実行」をクリックし、エラー等がないかチェックする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/e20a1756-4719-4777-c2d1-348f637b0546.png)

4.画面右上の「発行」をクリックし、内容を確認のうえ「保存して発行する」をクリックする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/2e163715-81b6-33c2-3e13-aafe1e0f8d31.png)

#4 動作検証
以上で探知機モデルを作成したので、ペイロードを送って動作を確認します。

##4-1 IoT Ruleの準備
IoT Eventsからもテストペイロードを送ることはできますが、入力等やや煩雑なので今回はIoT CoreのMQTTテストクライアントからルールアクションを利用して探知機モデル（正確には「入力」）にペイロードを渡します。
まず、探知機モデルにペイロードを渡すIoT Ruleを作成します。

1.IoT Coreのマネジメントコンソールで 左メニューのACT > ルール の順にクリック
2.「作成」をクリック
3.先に「アクションの追加」をクリック
4.「IoT Events入力にメッセージを送信する」を選択して、「アクションの選択」をクリック
5.ロールの作成をクリックし、今回のルール用にIAMロールを任意の名前で作成する
6.以下の通り入力、選択して「アクションの追加」をクリック

* 入力名：DetectionInput
* ロール：※手順5で作成したIAMロール

7.ルールの作成画面に戻るので、以下の通り入力して「ルールの作成」をクリック

* 名前：※任意の名前
* ルールクエリステートメント：※以下の通り

```sql
select * FROM 'test/DetectionInput'
```

8.入力AlertMinutesInputにペイロードを渡すルールも同様に作成する

* 名前：※任意の名前
* ルールクエリステートメント：※以下の通り

```sql
select * FROM 'test/AlertMinutesInput'
```

##4-2 動作検証
IoT CoreのMQTTテストクライアントからペイロードを送信します。
今回送るのは2種類のペイロードです。

**デバイスから通常送信される想定のペイロード**
トピック`test/DetectionInput`に送ります。
探知機モデルは通常ペイロードが最後に届いてから変数alert_minutesに設定した時間（分）の間に次の通常ペイロードがくるかどうかで、死活の判定をします。

```json:通常
{
    "deviceId": "device-0001",
    "timestamp": "2021-12-13T00:00:00Z",
    "temperature": 15.5,
    "humidity": 42.5
}
```

**設定変更のペイロード**
トピック`test/AlertMinutesInput`に送ります。
alertMinutesで変数alert_minutesに設定する時間を変更します。
また、excludedをTrueにすることで、死活監視の対象から外します。

```json:設定変更
{
    "deviceId": "device-0001",
    "alertMinutes": 30,
    "excluded": false
}
```

1.IoT Coreのマネジメントコンソール上で左メニューの テスト > MQTTテストクライアント の順にクリック
2.送信したペイロードを確認できるように、今回の送信先トピックをそれぞれサブスクライブする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/b391d199-e420-7d88-6c1a-f2d89c6183b7.png)

3.以下の通常ペイロードを送ったあと、IoT Events > 探知機モデル > 該当の探知機モデル の順にクリックし、deviceIdでディテクターが作成されていることを確認する

```json
{
    "deviceId": "device-0001",
    "timestamp": "2021-12-13T04:49:00Z",
    "temperature": 15.5,
    "humidity": 42.5
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/2e4b87c0-3d6f-bdc8-1abe-c5faeeff590f.png)

4.以下の通り設定値変更ペイロードを`"excluded":false`で送り、ディテクターの状態が「Normal」に遷移することを確認する

```json
{
    "deviceId": "device-0001",
    "alertMinutes": 5,
    "excluded": false
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/06a56aa2-1222-5961-3d29-5317af0bd1ff.png)

5.以下の通り通常ペイロードを送り、ディテクターに先程設定したalert_minutesの時間（分）でタイマーが作成されることを確認する

```json
{
    "deviceId": "device-0001",
    "timestamp": "2021-12-13T07:38:00Z",
    "temperature": 15.5,
    "humidity": 42.5
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/1ecb2a8e-357a-d487-d856-7e84f219cc6e.png)

6.タイマーがtimeoutするのを待ち、以下を確認する

* ディテクターの状態がAlertになる
* アラート通知のメールが届く

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/0d3ba045-7086-ab4d-2c0f-e0a7d6bda720.png)
※timeoutするまでにalert_minutesを変更したため、このキャプチャでは5から10に変わっている

メールは以下の通り通知された
> 件名：device-0001が接続されていません
本文：
device-0001が最後にデータを通知してから10分が経過しました。
デバイスが接続されていない可能性があります。
> 
最終通信時刻：2021-12-13 16:38:00

7.通常ペイロードを再度送ることで状態がNormalに戻ることを確認する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1001693/e54ac85a-304a-3385-957a-6e7aceb1fad7.png)
※今回は既にalert_minutes設定後なので、Normalに遷移した段階でまたタイマーが設定され、時間内にペイロードが届かないと再びAlertに遷移し、アラートが通知される

#5 備忘録：「入力」について
**①あくまでも探知機モデルで扱うペイロード上のデータに関する定義なので、以下の場合も問題なく動く**

* 「入力」で指定していないデータがペイロード上に含まれる
* 「入力」で指定したデータが一部ペイロード上に含まれない

**②「入力」の中身をあとから変更することができる**
1.IoT Eventsのマネジメントコンソール > 入力 > 該当の入力名 の順にクリックする
2.「編集」ボタンをクリック
3.以降は「入力」作成時同様の手順で更新ができる

**③探知機モデルの「入力」を付け替えるGUIが用意されていない**
ここまでの手順でも分かる通り、「入力」は単一の探知機モデルに紐付けられたものではありません。
IoT Eventsに登録された「入力」が受け取ったペイロードを探知機モデルが参照しに行っている、という関係のようです。

#おわりに
IoT Eventsを使って死活監視を作成してきました。
GUIからサクサクとこうした機能を作れるのはとても便利な半面、ロジックを増やすほどにIoT Eventsではどう実装すればいいか、という難しさも出てくると思います。
簡単に作れる分、細かい制御を組み込むのはやや難しいのかな、という印象はあります。

今回はAlert時のみ通知を出しましたが、AlertからNormalに戻るタイミングでも通知を出したり、APIを使って現在のディテクターの状態を取得して画面表示するなど、更に色々なことをさせることもできると思います。

#参考文献（文中には登場していないもの）
* [Expressions - IoT Events](https://docs.aws.amazon.com/ja_jp/iotevents/latest/developerguide/iotevents-expressions.html)
  * 評価式で使える符号や組込み関数はこちらを参照
  * 今回はcurentInput()、timeout()を使っている