この記事は誰に向けた記事?
-
対象
- AWSを利用したIoTの可視化に興味がある方
-
必要
- AWSの知識があり、基本的な認証や各サービスの説明が不要であること
- Node.js、Reactの知識があり、環境構築の説明が不要であること
iot-app-kitとはナニモノ?
iot-app-kit
は、Reactを使って、QuickSightやGrafanaのようなダッシュボードを開発できるライブラリです。AWSが公開しています。
ダッシュボードには、SiteWiseに蓄積したIoTデータ、TwinMakerの3Dデジタルツイン、Kinesis Video Streamsの動画などを表示することができます。データは5秒ごとに同期され、リアルタイムな機器の状態が表示されます。iot-app-kitは、必要なリソース管理や同期処理、可視化ツールの提供までをやってくれるライブラリです。
可視化ダッシュボードには、下の画像にあるようなグラフが簡単に配置できます。線グラフや棒グラフ、KPI、3Dモデルなどが使えます。
ただ、Reactを使うのならその強みを生かしたい。データの内容によって処理を分岐させたいこともあります。線グラフや棒グラフ以外の独自のグラフ、データによって変わるメッセージを増やしたいこともあります。たとえば、機械の温度が一定値を超えたときに「オーバーヒートしそうです、〇〇を操作して停止してください」みたいなメッセージが出せたら素敵ですよね。
この記事では、iot-app-kit
に独自の可視化コンポーネントを追加する方法を紹介します。
具体的にやること
テキスト文中にリアルタイムな機器データを表示する可視化コンポーネントを作ります。
この20
が機器の状態に合わせてリアルタイムで変化します。
Reactのソースコードは下みたいな感じです。
// 独自コンポーネントを定義する
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の使い方
// インポートしてくる
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)でラップする
// 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の更新で挙動が変わることを想定して外に出すようにします。
変更前
// 44行目
useEffect(
() => {
// ... 中略
return () => {
// ...中略
};
},
// 82行目
queries.map((query) => query.toQueryString()) // <- 無限ループの原因
);
// ...中略
// 92行目
return { dataStreams: timeSeriesData?.dataStreams || [] };
変更後
const [updateExecute, setUpdateExecute] = useState<number>(0); // + 追加
// 44行目
useEffect(
() => {
// ... 中略
return () => {
// ...中略
};
},
// 82行目
[updateExecute] // + 外部から参照できるように変更
);
// ...中略
// 92行目
return { dataStreams: timeSeriesData?.dataStreams || [], dispatchTrigger: setUpdateExecute }; // + 戻り値でset関数を返却
編集したuseTimeSeriesDataを使ってみる
以下のような独自コンポーネントを作ります。
// データ型のインポートが必要
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でデータを流し込みます
// 変更したuseTimeSeriesDataをインポートしてくる
import { useTimeSeriesData } from "./useTimeSeriesData";
// SiteWiseもインポート
import { initialize } from "@iot-app-kit/source-iotsitewise"
// 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の画面を立ち上げると、ブラウザには下の画面が表示されます。
クラウド側でアセットを違う数字(たとえば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プロジェクトの環境構築
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での構築が必要です
SiteWiseアセットの作成ソース(CDKのソース)
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
})
}
}
SiteWiseへのデータ送信(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)
)
更新した後のuseTimeSeriesData.ts
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 };
};
useTimeSeriesDataを呼び出すApp.tsx
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