iot-app-kitのuseTimeSeriesData関数を使って、オリジナルな可視化部品を作る

# この記事は誰に向けた記事？

- **対象**
    - AWSを利用したIoTの可視化に興味がある方
- **必要**
    - AWSの知識があり、基本的な認証や各サービスの説明が不要であること
    - Node.js、Reactの知識があり、環境構築の説明が不要であること

# iot-app-kitとはナニモノ？

`iot-app-kit`は、Reactを使って、QuickSightやGrafanaのようなダッシュボードを開発できるライブラリです。AWSが公開しています。

![data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/d1228a5e-a814-ec06-59b2-ee89865472ac.png)

ダッシュボードには、SiteWiseに蓄積したIoTデータ、TwinMakerの3Dデジタルツイン、Kinesis Video Streamsの動画などを表示することができます。データは5秒ごとに同期され、リアルタイムな機器の状態が表示されます。iot-app-kitは、必要なリソース管理や同期処理、可視化ツールの提供までをやってくれるライブラリです。

![structure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/8e751143-b28c-a1f3-67cf-43efb90f0270.png)

可視化ダッシュボードには、下の画像にあるようなグラフが簡単に配置できます。線グラフや棒グラフ、KPI、3Dモデルなどが使えます。

![159107236-ea95e7ba-a89c-43e6-a34c-c5ea1dd37e8b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/53e409ba-04e9-639f-d1e1-5aa2f86eed3b.png)

ただ、Reactを使うのならその強みを生かしたい。データの内容によって処理を分岐させたいこともあります。線グラフや棒グラフ以外の独自のグラフ、データによって変わるメッセージを増やしたいこともあります。たとえば、機械の温度が一定値を超えたときに「オーバーヒートしそうです、〇〇を操作して停止してください」みたいなメッセージが出せたら素敵ですよね。

この記事では、`iot-app-kit`に独自の可視化コンポーネントを追加する方法を紹介します。

# 具体的にやること

テキスト文中にリアルタイムな機器データを表示する可視化コンポーネントを作ります。

![aden_arabia.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/56bea60f-ef73-4088-a8e3-786833e6d694.png)

この`20`が機器の状態に合わせてリアルタイムで変化します。  
Reactのソースコードは下みたいな感じです。

```typescript
// 独自コンポーネントを定義する
function CustomContainer(property: CustomContainerProperty) {
  // SiteWiseからリアルタイムなデータを取得する
  let value = getLatestDataFromStreams(property.dataStreams);
  // データを表示する画面部品を返す
  return (
    <div>
      <div>ぼくは{value}歳だった。</div>
      <div>それがひとの一生でいちばん美しい年齢だなどとだれにも言わせまい</div>
    </div>
  )
}
```

タイマー処理やAPI実行、リソースの管理はapi-app-kitがやってくれます。

# 実現方法：利用する関数

`iot-app-kit`には、時系列データを独自コンポーネントに流し込む関数があります。
以下、公式のREADMEを参考にして書き出します。

**iot-app-kit::useTimeSeriesData関数**
https://github.com/awslabs/iot-app-kit/blob/main/docs/useTimeSeriesData.md

> **useTimeSeriesDataとは？：解説**
> このReactフックは、独自のReact コンポーネントから、時系列データを簡単に利用できるようにするための関数です。
>
> The useTimeSeriesData react hook is a function which allows you to easily utilize time series data within your react components from IoT App Kit datasources.

**useTimeSeriesDataの使い方**

```javascript
// インポートしてくる
import { useTimeSeriesData } from '@iot-app-kit/react-components';
const { dataStreams } = useTimeSeriesData({ query });

// データを流し込む
const CustomVisualization = () => {
    const { dataStreams } = useTimeSeriesData({ query });
    // オリジナルなコンポーネントにデータを受け渡す
    return <VisualizationComponent dataStreams={dataStreams} />
}
```

素晴らしい機能ですが、まだ**開発中の機能です**。

2023/02/13にREADMEが追加、クラスは最新のパッケージに入っていますが、まだドキュメント通りに使える状態にはなっていないようです。サンプルの通りに実装しても、`@iot-app-kit/react-components`のパスではインポートできません。distの下から引っ張ってきてもうまく実行できません。無理に呼び出すとAPIの呼び出しが無限ループを起こします。

数行だけ手を加えると使えるようになりますので、その手順を解説します。

# useTimeSeriesDataのソースをgitから拝借してくる

v3.0.0時点のソースを対象にします。

**usetimeSeriesDataのソースファイルの場所**
https://github.com/awslabs/iot-app-kit/blob/main/packages/react-components/src/hooks/useTimeSeriesData/useTimeSeriesData.ts

このuseTimeSeriesData.tsのソースコードを自身のReactのプロジェクトにコピーして、インポートで出るエラーを解消します。  
- **【変更点】**
- importのパスを変更する
- uuidは固定文字列 or 外部参照に置き換えればよいので削除する
- 76行目の`provider.current`をif (provider && provider.current)でラップする

```diff
// useTimeSeriesData.ts::12行目付近
- import { v4 as uuid } from 'uuid';
- import { bindStylesToDataStreams } from '../utils/bindStylesToDataStreams';
- import { combineTimeSeriesData } from '../utils/combineTimeSeriesData';
- import { useViewport } from '../useViewport';
+ import { bindStylesToDataStreams } from '@iot-app-kit/react-components/dist/hooks/utils/bindStylesToDataStreams';
+ import { combineTimeSeriesData } from '@iot-app-kit/react-components/dist/hooks/utils/combineTimeSeriesData';
+ import { useViewport } from '@iot-app-kit/react-components/dist/hooks/useViewport';

// 46行目付近
- const id = uuid();
+ const id = "uuid-any-data"; // なんでもOK
```

82行目に無限ループを起こす処理があるため、これを修正します。  
※`[]`だとか`[0]`に書き変えるだけでよいのですが、今後のiot-app-kitの更新で挙動が変わることを想定して外に出すようにします。

**変更前**
```typescript
  // 44行目
  useEffect(
    () => {
      // ... 中略
      return () => {
          // ...中略
      };
    },
    // 82行目
    queries.map((query) => query.toQueryString()) // <- 無限ループの原因
  );
  // ...中略
  // 92行目
  return { dataStreams: timeSeriesData?.dataStreams || [] };
```

**変更後**
```typescript
  const [updateExecute, setUpdateExecute] = useState<number>(0); // + 追加  
  // 44行目
  useEffect(
    () => {
      // ... 中略
      return () => {
          // ...中略
      };
    },
    // 82行目
    [updateExecute] // + 外部から参照できるように変更
  );
  // ...中略
  // 92行目
  return { dataStreams: timeSeriesData?.dataStreams || [], dispatchTrigger: setUpdateExecute }; // + 戻り値でset関数を返却
```

# 編集したuseTimeSeriesDataを使ってみる

以下のような独自コンポーネントを作ります。

```typescript
// データ型のインポートが必要
import { Primitive } from "@iot-app-kit/react-components/dist/common/dataTypes";
import { DataStream } from '@iot-app-kit/core';

// コンポーネントの引数を定義する
interface CustomContainerProperty {
  dataStream: DataStream<Primitive>[]
}

// 独自コンポーネントを定義する
function CustomContainer(property: CustomContainerProperty) {
  // データを取得する
  let value = "-";
  try {
    // SiteWiseから最新のデータを取得する
    const dataList = property.dataStream[0].data;
    // 昇順でデータが送られてくるので最新値は配列の最後にある
    // xは日時データ、yが値
    value = `${dataList[dataList.length - 1].y}`;
  } catch {}
  // データを表示する画面部品を返す
  return (
    <div>
      <div>ぼくは{value}歳だった。</div>
      <div>それがひとの一生でいちばん美しい年齢だなどとだれにも言わせまい</div>
    </div>
  )
}
```

この独自部品にuseTimeSeriesDataでデータを流し込みます

```typescript
// 変更したuseTimeSeriesDataをインポートしてくる
import { useTimeSeriesData } from "./useTimeSeriesData";
// SiteWiseもインポート
import { initialize } from "@iot-app-kit/source-iotsitewise"
```

```typescript
  // SiteWiseとの接続情報を設定する
  const { query } = initialize({ /* <認証情報> */ });

  // 時系列データの取得クエリを設定する
  const timeSeriesQuery = query.timeSeriesData({
    assets: [
      {
        assetId: "<SiteWiseのアセットID>",
        properties: [
          {
            propertyId: "<SiteWiseのプロパティID>",
          }
        ]
      }
    ]
  })

  // usetimeSeriesDataを発行して、データを参照する
  const timeSeries = useTimeSeriesData({
    queries: [timeSeriesQuery],
    viewport: {start: new Date(/**開始日時*/), end: new Date("/**終了日時*/")},
    settings: {
      resolution: '0', // 重要：データ解像度
      fetchMostRecentBeforeEnd: true, // 最新値を取得する？
      fetchFromStartToEnd: true // 開始日時から終了日時まで取得する？
    }
  })
  
  useEffect(() => {
    // 無限ループを停めるために0を入れる
    // もしクエリを更新したときは、
    //   timeSeries.dispatchTrigger(Date.now())
    // みたいに呼び出せば更新がかかる
    timeSeries.dispatchTrigger(0);
  }, [])

  // 独自のコンポーネントを返却する
  return (
    <div>
      <CustomContainer dataStream={timeSeries.dataStreams}></CustomContainer>
    </div>
  )
```

これだけで実装は完了です。タイマー処理やリソースの管理はuseTimeSeriesDataの中でやってくれるため、こちらは何も気にしなくてOKです。

対象のSiteWiseのアセットに`20`を登録して、Reactの画面を立ち上げると、ブラウザには下の画面が表示されます。

![aden_arabia.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/56bea60f-ef73-4088-a8e3-786833e6d694.png)

クラウド側でアセットを違う数字（たとえば`21`）に更新すると、ほぼリアルタイムでReactの画面も更新されます。

# まとめ

以上、独自のコンポーネントを作る方法を紹介しました。
  
リアルタイムな時系列データがReact上で扱えるので、
- 一部のグラフを別のライブラリ（例：Chart.jsなど）で表示したい
- 値に応じてグラフの表示内容を分岐させたい

といった要望に対応することができます。デジタルツインの表現の幅を広げられると思います

# 付録

ご自身の環境で試される方向けに、検証用のリソースはこちらです  

バージョン情報

- @iot-app-kit/react-components : 2.7.0
- @iot-app-kit/core : 2.7.0
- @iot-app-kit/source-iotsitewise : 2.7.0
- react : 18.2.0

`useTimeSeriesData.ts`は、バージョン3.0.0のものを参照しています

Reactプロジェクトの環境構築

```bash
npm create vite@latest
# Reactを選択
# Typescript + SWCを選択

# cdで <対象のディレクトリへ> 移動して実行
npm install
npm install @iot-app-kit/core @iot-app-kit/react-components @iot-app-kit/source-iotsitewise
```

> ※注意：TwinMaker系はViteでは動かない（.hdrファイルとか読めないファイルが3D部分に入っている）ので、TwinMakerを扱う場合はViteではなくcreate-react-appでの構築が必要です

<details><summary>SiteWiseアセットの作成ソース（CDKのソース）</summary><div>

```typescript
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {CfnAssetModel, CfnAsset} from "aws-cdk-lib/aws-iotsitewise"
import { CfnOutput } from 'aws-cdk-lib';

export class AssetsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // プロパティの物理ID
    const propertyLogicalId = "state-value";
    // アセットにひもづいたプロパティの名前
    // ここにデータを投げたら編集できるよのパス
    // （obj.value = newval; の、"obj.value"みたいな感じ）
    const propertyAliasPath = "state/value/1"

    // アセットモデルを作成する（クラス定義みたいな感じ）
    const model = new CfnAssetModel(this, "AssetModel", {
      assetModelName: "State",
      assetModelProperties: [
        {
          type: {
            typeName: "Measurement"
          },
          logicalId: propertyLogicalId,
          name: "Value",
          dataType: "INTEGER",
        }
      ]
    })

    // アセットを作成する（インスタンス化みたいな感じ）
    const asset = new CfnAsset(this, "ValueAsset", {
      assetModelId: model.attrAssetModelId,
      assetName: "Object1",
      assetProperties: [
        {
          logicalId: propertyLogicalId,
          alias: propertyAliasPath
        }
      ]
    });

    new CfnOutput(this, "AssetId", {
      exportName: "AssetId",
      value: asset.attrAssetId
    })
    new CfnOutput(this, "PropertyAlias", {
      exportName: "PropertyAlias",
      value: propertyAliasPath
    })
  }
}
```
</div></details>

<details><summary>SiteWiseへのデータ送信（pythonスクリプト）</summary><div>

```python
import boto3
from uuid import uuid4
from datetime import datetime
import sys

def main(property_alias: str, value: int):
    client = boto3.client("iotsitewise")
    client.batch_put_asset_property_value(
        entries=[{
                "entryId": str(uuid4()),
                "propertyAlias": property_alias,
                "propertyValues": [
                    {
                        "value": {
                            "integerValue": value
                        },
                        "timestamp": {
                            "timeInSeconds": int(datetime.now().timestamp()),
                            "offsetInNanos": 0
                        },
                        "quality": "GOOD"
                    }
                ]
            }
        ]
    )
    pass

# 使い方 ::: "20"を送信する場合
# python sender.py 20
# 
if len(sys.argv) == 2:
    main(
        property_alias="state/value/1",
        value=int(sys.argv[1], 10)
    )
```
</div></details>

<details><summary>更新した後のuseTimeSeriesData.ts</summary><div>

```typescript
import { useEffect, useState, useRef } from 'react';
import {
  Viewport,
  TimeSeriesData,
  TimeSeriesDataRequest,
  TimeQuery,
  TimeSeriesDataRequestSettings,
  ProviderWithViewport,
  StyleSettingsMap,
  combineProviders,
} from '@iot-app-kit/core';
import { bindStylesToDataStreams } from '@iot-app-kit/react-components/dist/hooks/utils/bindStylesToDataStreams';
import { combineTimeSeriesData } from '@iot-app-kit/react-components/dist/hooks/utils/combineTimeSeriesData';

import { useViewport } from '@iot-app-kit/react-components/dist/hooks/useViewport';

const DEFAULT_SETTINGS: TimeSeriesDataRequestSettings = {
  resolution: '0',
  fetchFromStartToEnd: true,
};

const DEFAULT_VIEWPORT = { duration: '10m' };

export const useTimeSeriesData = ({
  queries,
  viewport: passedInViewport,
  settings = DEFAULT_SETTINGS,
  styles
}: {
  queries: TimeQuery<TimeSeriesData[], TimeSeriesDataRequest>[];
  viewport?: Viewport;
  settings?: TimeSeriesDataRequestSettings;
  styles?: StyleSettingsMap;
}) => {
  const [timeSeriesData, setTimeSeriesData] = useState<TimeSeriesData | undefined>(undefined);
  const [updateExecute, setUpdateExecute] = useState<number>(0);

  const { viewport: injectedViewport } = useViewport();
  const viewport = passedInViewport || injectedViewport || DEFAULT_VIEWPORT;

  const prevViewport = useRef<undefined | Viewport>(undefined);
  const provider = useRef<undefined | ProviderWithViewport<TimeSeriesData[]>>(undefined);

  useEffect(
    () => {
      const id = "uuid-any-data";
      provider.current = combineProviders(
        queries.map((query) =>
          query.build(id, {
            viewport,
            settings,
          })
        )
      );

      provider.current.subscribe({
        next: (timeSeriesDataCollection: TimeSeriesData[]) => {
          const timeSeriesData = combineTimeSeriesData(timeSeriesDataCollection, viewport);
          setTimeSeriesData({
            viewport,
            annotations: timeSeriesData.annotations,
            dataStreams: bindStylesToDataStreams({
              dataStreams: timeSeriesData.dataStreams,
              styleSettings: styles,
              assignDefaultColors: false,
            }),
          });
        },
      });

      return () => {
        // provider subscribe is asynchronous and will not be complete until the next frame stack, so we
        // defer the unsubscription to ensure that the subscription is always complete before unsubscribed.
        setTimeout(() => {
            if (provider && provider.current) {
          provider.current.unsubscribe();
          provider.current = undefined;
            }
          prevViewport.current = undefined;
        });
      };
    },
    [updateExecute]
  );

  useEffect(() => {
    if (prevViewport.current != null) {
      provider.current?.updateViewport(viewport);
    }
    prevViewport.current = viewport;
  }, [viewport]);

  return { dataStreams: timeSeriesData?.dataStreams || [], dispatchTrigger: setUpdateExecute };
};
```
</div></details>

<details><summary>useTimeSeriesDataを呼び出すApp.tsx</summary><div>

```typescript
import './App.css'
import { initialize } from "@iot-app-kit/source-iotsitewise"
import { IoTSiteWiseClient } from '@aws-sdk/client-iotsitewise';
import { IoTEventsClient} from "@aws-sdk/client-iot-events";
import { useTimeSeriesData } from "./useTimeSeriesData";
import { Primitive } from "@iot-app-kit/react-components/dist/common/dataTypes";
import { DataStream } from '@iot-app-kit/core';
import { useEffect } from 'react';

interface CustomContainerProperty {
  dataStream: DataStream<Primitive>[]
}

function CustomContainer(property: CustomContainerProperty) {
  let value = "-";
  try {
    // 最新のデータを取得する
    const dataList = property.dataStream[0].data;
    value = `${dataList[dataList.length - 1].y}`;
  } catch {}
  return (
    <div>
      <div>ぼくは{value}歳だった。</div>
      <div>それがひとの一生でいちばん美しい年齢だなどとだれにも言わせまい</div>
    </div>
  )
}

function App() {

  const ACCESS_KEY_ID = "<アクセスキー>";
  const SECRET_ACCESS_KEY = "<シークレットアクセスキー>";
  
  const iotSiteWiseClient = new IoTSiteWiseClient({
    region: "ap-northeast-1",
    credentials: {
      accessKeyId: ACCESS_KEY_ID,
      secretAccessKey: SECRET_ACCESS_KEY
    }
  });
  const iotEventsClient = new IoTEventsClient({
    region: "ap-northeast-1",
    credentials: {
      accessKeyId: ACCESS_KEY_ID,
      secretAccessKey: SECRET_ACCESS_KEY
    }
  })
  const { query } = initialize({ iotSiteWiseClient, iotEventsClient });
  const timeSeriesQuery = query.timeSeriesData({
    assets: [
      {
        assetId: "<アセットID>",
        properties: [
          {
            propertyId: "<プロパティID>",
          }
        ]
      }
    ]
  })

  const timeSeries = useTimeSeriesData({
    queries: [timeSeriesQuery],
    viewport: {start: new Date("2023-03-10 00:00:00"), end: new Date("2023-03-12 00:00:00")},
    settings: {
      resolution: '0',
      fetchMostRecentBeforeEnd: true,
      fetchFromStartToEnd: true
    }
  })
  
  useEffect(() => {
    timeSeries.dispatchTrigger(0);
  }, [])

  return (
    <div>
      <CustomContainer dataStream={timeSeries.dataStreams}></CustomContainer>
    </div>
  )
}

export default App
```
</div></details>