AWS
three.js
lambda
aws-iot

Three.jsとAWSを連携させてIoTっぽいことしてみた

以前作ったThree.jsとAWSの連携話にもう少し手を加えて、IoTっぽいことをしてみました。

Three.jsのかっこいいサンプルとAWSを連携させてみた

やりたいこと

  1. 表示を監視画面ぽい感じにする
  2. 監視対象(デバイス)のデータはAWSから取得する
  3. 監視データを送付する仕組みを用意する
  4. 監視データの取得はひとまず任意のタイミングで直近のものを取る

この辺ができればいいかな、と。

構成

こんな感じを目指します。

スクリーンショット 2018-01-04 12.11.02.png

デバイス(下の方)からはAWS IoTを通じてデータをDynamoDBに保存できるようなフローを作ります。
画面(上の方)はアクセスした際にデバイスが送付したデータをDynamoDBから取得して表示する仕組みとします。

表示を監視画面ぽい感じにする

とりあえず「いらすとや」で見つけた日本地図を今回は使います。日本全体の監視をイメージします。

で、こんな気持ち悪い表示になりました。(背景は黒だとアンダーグラウンドな感じがしてしまうので、白にしています。)

スクリーンショット 2017-12-18 12.08.20.png

ただし、このままだとカメラのズームに背景画像はついてこないので、カメラのズームに背景画像も同じようなズームイン/アウトがされるような仕組みに変更します。(意外とこれが面倒・・・)

地図もカメラ操作の対象とする

ただの背景ではカメラ操作に追従しませんので、監視用のコンポーネントと地図がズームイン/アウトでずれてしまいます。そこでThree.jsで扱っているカメラで追従される仕組みに乗せます。

まずはThree.jsで使っているSceneとRendererを地図用に用意します。これはオブジェクトと一緒のものにしてしまうと、アニメーションなども同じように設定されてしまうので、地図としてあくまで余計なものを設定しないように重複するような形でですが、作成しています。(何か良いやり方があったらここは知りたいです。)

camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 1, 10000);
camera.position.z = 4000;

scene = new THREE.Scene();
scene_map = new THREE.Scene();
renderer = new THREE.CSS3DRenderer();
renderer_map = new THREE.WebGLRenderer({ alpha:true });

_mapとサフィックスをつけている変数が地図用のSceneとRendrerになります。RendererはWebGLRendereにしています(CSS3DRendererではなぜかうまく表示できなかった)。WebGLRendererで作成するRendererは、背景を透過にしています。

初期化メソッドとしてinitメソッドが定義されていますが、地図の初期化用としてinitMapメソッドを新たに作成し、その中で地図の初期表示は行います。

    function initMap() {
        var texture = new THREE.TextureLoader().load('images/Japan.png', function(texture) {renderer_map.render(scene_map, camera)});
        var material = new THREE.MeshBasicMaterial({ map: texture });

        var geometry = new THREE.PlaneGeometry(2500, 2500);
        var object = new THREE.Mesh( geometry, material );

        scene_map.add(object);
        scene_map.backgroundColor = new THREE.Color( 0xFFFFFF );

        renderer_map.setSize(window.innerWidth, window.innerHeight);
        renderer_map.domElement.style.position = 'absolute';
        renderer_map.setClearColor( 0xFFFFFF, 1 );
        renderer_map.domElement.style.zIndex = "0"; 
        document.getElementById('container').appendChild(renderer_map.domElement);

        renderer_map.render(scene_map, camera);

    }

これでズームなどにも対応した表示になりました。

監視対象のデータはAWSから取得する

データ構造の考察

今は監視対象としたいデータ(サンプルだと元素記号)はHTMLに静的に記載されていますので、それをDynamoDBから動的に取得できるようにします。

var table = [
    "H", "Hydrogen", "1.00794", 1, 1,
    "He", "Helium", "4.002602", 18, 1,
    "Li", "Lithium", "6.941", 1, 2,
    "Be", "Beryllium", "9.012182", 2, 2,
    "B", "Boron", "10.811", 13, 2,
:
:

こんな感じのデータ形式なので、こちらを監視対象のデバイスデータっぽくしてみます。

var table = [
    "A-001", "A", 1, 1,
    "A-002", "A", 2, 1,
    "B-001", "B", 1, 2,
    "B-002", "B", 2, 2,
    "B-003", "B", 3, 2,
:
:

こんな感じで。それぞれの項目の意味は以下の通りです。

No 名称 意味
1 ID ID
2 グループ名 デバイスが所属するグループ(あとで集計に使う、などを想定)
3 x座標(位置) 位置情報(x方向)
4 y座標(位置) 位置情報(y方向)

こうすれば、なんとなくですがデバイスのマスタデータっぽくなり、かつ現状のサンプルの実装を生かせそうですね。

とりあえずこのデータをいくつか用意してDynamoDBに取り込みます。データ投入には以前作成したdata-insert.pyを利用します。pos_xpos_yは実際のx座標とy座標を表す値を入れることとします。

id,group,pos_x,pos_y
"A-001","A", 1650, 1300
"A-002","A", 400, 1200
"B-001","B", 0, 1600
"B-002","B", 1800, 100
"B-003","B", 1500, 800

コマンドで一括投入。

$ python data-insert.py sample-data.csv us-east-1 aws-three-iot
Data insert start.
{'pos_x': 1650, 'pos_y': 1300, 'group': 'A', 'id': 'A-001'}
{'pos_x': 400, 'pos_y': 1200, 'group': 'A', 'id': 'A-002'}
{'pos_x': 0, 'pos_y': 1600, 'group': 'B', 'id': 'B-001'}
{'pos_x': 1800, 'pos_y': 100, 'group': 'B', 'id': 'B-002'}
{'pos_x': 1500, 'pos_y': 800, 'group': 'B', 'id': 'B-003'}
Data insert finish.

無事、できました。ソートキーを設定していないので、並び順は無茶苦茶ですが。

スクリーンショット 2017-12-31 14.22.26.png

さて、このデータを画面表示してみます。(現状だと数値がない状態では既存のコードは使えないので、既存コードで数値を表示する部分はコメントアウトしました。)

まずはLambdaコードです。こちらはとても単純。単なる全件取得です。DecimalEncoderは公式ページをコピペしました。

Step 3: Create, Read, Update, and Delete an Item

import boto3
from boto3.dynamodb.conditions import Key, Attr
import json
import decimal

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('aws-three-iot')

class DecimalEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, decimal.Decimal):
            if o % 1 > 0:
                return float(o)
            else:
                return int(o)
        return super(DecimalEncoder, self).default(o)

def lambda_handler(event, context):
    response = table.scan()['Items']
    return json.dumps(response, indent=4, separators=(',', ': '), cls=DecimalEncoder)

API Gatewayを設定します。以前作成したGatewayにGETメソッドを一つ追加します。

スクリーンショット 2017-12-18 14.44.44.png

SDKを生成した後は、実際にGETで取得してみます。とりあえずscript.jsのreadyメソッド内にこんなメソッドを書いて確認。

    apigClient.devicesGet(params, {}, additionalParams)
    .then(function(result){
        alert(result.data);
    });

上手く取得できたようです。

スクリーンショット 2017-12-18 16.10.47.png

取得が確認できれば、これを元々のtableデータに適用し、オブジェクトの表示を行ってみます。(元々1つの配列を5個ずつのデータ区切りとして扱っていた素敵な(^^;実装だったため、その辺も少し修正)

    apigClient.devicesGet(params, {}, additionalParams)
        .then(function (result) {
            init(JSON.parse(result.data));
            animate();
        });

initを呼び出す際に、Ajax通信の戻り値をパラメータ渡しします。

initメソッドの中では、5個ごとのデータ区分けではなく、1レコードごとにデータをオブジェクトに格納するように変更しています。

        for (var i = 0; i < table.length; i++) {

            var element = document.createElement('div');
            element.className = 'element';
            element.id = table[i].id;
            element.style.backgroundColor = 'rgba(0,127,127,' + (Math.random() * 0.5 + 0.25) + ')';

            var symbol = document.createElement('div');
            symbol.className = 'symbol';
            symbol.textContent = table[i].id;
            element.appendChild(symbol);

            var details = document.createElement('div');
            details.className = 'details';
            details.innerHTML = table[i].group;
            element.appendChild(details);
        }

無事取得できました。それっぽく各地域を示すような箇所にオブジェクトが表示されています。

スクリーンショット 2017-12-31 14.22.37.png

実際の値を表示する

このままだとIDがただ表示されているだけなので、監視している値とIDを表示するように、オブジェクト内の表示を修正します。

まずは値を表示してみます。現在IDを表示している部分に値を表示するようにします。

            var symbol = document.createElement('div');
            symbol.className = 'symbol';
            symbol.textContent = table[i].id;
            element.appendChild(symbol);

この部分がIDを大きく表している箇所になるので、table[i].idではなく初期値では「0」を表示するようにします。

次に、実際のデータを一旦入れてみます。「A-001」にこんなデータを投入します。

id,timestamp,value,score
"A-001","2018-01-01T00:00:00",120,0
"A-001","2018-01-01T00:01:00",121,0
"A-001","2018-01-01T00:02:00",116,0
"A-001","2018-01-01T00:03:00",128,0
$ python data-insert.py sample-t-data.csv us-east-1 aws-three-iot-monitor
Data insert start.
{'timestamp': '2018-01-01T00:00:00', 'score': 0, 'id': 'A-001', 'value': 120}
{'timestamp': '2018-01-01T00:01:00', 'score': 0, 'id': 'A-001', 'value': 121}
{'timestamp': '2018-01-01T00:02:00', 'score': 0, 'id': 'A-001', 'value': 116}
{'timestamp': '2018-01-01T00:03:00', 'score': 0, 'id': 'A-001', 'value': 128}
Data insert finish.

データは投入できました。次にDynamoDBからscoreだけではなくvalueも取得するように処理を書き換えます。bodyはこんな感じで変更不要です。

var body = {

    "label_id": "id",
    "label_range": "timestamp",
    "id": [
        "A-001"
    ],
    "aggregator": "latest",
    "time_from": "2018-01-01T00:00:00",
    "time_to": "2018-01-01T00:04:00",
    "params": {
        "range": "timestamp"
    }
};

getDataメソッドの中で取得した値からscore値だけを元に色の計算をしているのが元々の処理でしたが、ここにvalueの値を書き込むような処理を追加します。

for (var index = 0; index < resultJson.length; index++) {
    var resultObj = new Object();
    resultObj.id = resultJson[index].id;
    resultObj.score = resultJson[index].score;
    resultObj.value = resultJson[index].value;    // 追加
    resultObjArray[index] = resultObj;
}

この保持した値を、対象となるオブジェクトに反映します。

for (var resultObj of resultObjArray) {
   $('#' + resultObj.id + '_symbol')[0].textContent = resultObj.value;
}

これで動的に値を変えられるところまでできました。

スクリーンショット 2018-01-01 15.10.36.png

このままだとどのオブジェクトがどのIDかわからないので、IDを出すようにします。

CSSと表示の処理を少し変えます。こんな感じに。.numberのfont-sizeと.symbolのtopを少し変えて表示が被らず、かつIDがみやすく表示されるようにしました。

.element .number {
    position: absolute;
    top: 20px;
    right: 20px;
    font-size: 32px;
    color: rgba(127,255,255,0.75);
}

.element .symbol {
    position: absolute;
    top: 60px;
    left: 0px;
    right: 0px;
    font-size: 60px;
    font-weight: bold;
    color: rgba(255,255,255,0.75);
    text-shadow: 0 0 10px rgba(0,255,255,0.95);
}

また一時的にコメントアウトしていたこの処理を復活させます。(変数名を同時に修正しています。)

var idElement = document.createElement( 'div' );
idElement.className = 'number';
idElement.textContent = table[i].id;
element.appendChild(idElement);

IDが表示されるようになりました。(若干見辛いですが)

スクリーンショット 2018-01-01 16.17.06.png

監視データを送付する仕組みを用意する

AWS IoT側の設定

送付側(デバイス)の準備をします。AWS側はAWS IoTを利用して、ルールによって受け取ったデータをそのままDynamoDBに流す手抜きでとりあえず間に合わせます。AWS IoTでACTや証明書の設定などをまず実施します。(詳細手順は省略)

DynamoDB側の設定は、「データベーステーブル (DynamoDBv2) の複数列にメッセージを分割する」という要件にとてもマッチした設定があったので、こちらを利用します。

スクリーンショット 2018-01-01 19.38.04.png

データ送付用のプログラム作成

実際にデータを送るスクリプトは公式ページを参考にしながらこんな感じでNode.jsで組みます。

var awsIot = require('aws-iot-device-sdk');


var device = awsIot.device({
   keyPath: <YourPrivateKeyPath>,
  certPath: <YourCertificatePath>,
    caPath: <YourRootCACertificatePath>,
  clientId: <YourUniqueClientIdentifier>,
      host: <YourCustomEndpoint>
});

var payload = {
    "id": "A-002",
    "timestamp": "2018-01-01T00:00:00",
    "value": 120,
    "score": 0
};

device
  .on('connect', function() {
    console.log('connect');
    device.subscribe('aws-three-iot-demo/#');
    device.publish('aws-three-iot-demo/A-002', JSON.stringify(payload));
  });

device
  .on('message', function(topic, payload) {
    console.log('message', topic, payload.toString());
  });

topicの2番目の値がそのままIDとしてDynamoDBに保持されるイメージです。これをまずはnodeコマンドで実施すると、DynamoDBへのデータ格納の確認ができます。

$ node js/iot-demo-device.js
connect
message aws-three-iot-demo/A-002 {"id":"A-002","timestamp":"2018-01-01T00:00:00","value":120,"score":0}

AWSの進化は素晴らしいですね。昔はPayloadという一つのカラムに全データをJSON形式で投入するしかできなかったんですが、フォーマットさえ合わせれば、DynamoDBの特定のカラムとして扱ってくれるようになりました。こんな感じでデータが入って来ます。完璧です。

スクリーンショット 2018-01-01 19.41.39.png

データ送付をコマンドからではなくWeb画面から実施可能にする

データ送付部はこのままでもいいのですが、画面からポチポチできるとありがたいので、ちょっとした画面を作成します。

画面からAWS IoTにデータを送付するのって結構面倒なので、クラメソさんのこの記事を超参考にさせてもらいました(ほぼコピペ)

AWS IoTのMQTT over WebSocketにHTMLから接続してみた

iot-demo-device.html
<html>
    <head>
        <title>IoT device demo</title>
        <meta charset="utf-8">
        <script src="https://code.jquery.com/jquery-2.2.4.min.js" ></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.20.1/moment.min.js" type="text/javascript"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/core.min.js" type="text/javascript"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/hmac.min.js" type="text/javascript"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/sha256.min.js" type="text/javascript"></script>

        <script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.min.js" type="text/javascript"></script>
        <script src="../js/script.js"></script>
    </head>
    <body>

        <h2>IoT device demo</h2>
        <div id="dataArea">
            <table id="dataTable">
                <tr>
                    <td><label>ID: </label></td><td><input id="id" type="input" /></td>
                </tr>
                <tr>
                    <td><label>Value: </label></td><td><input id="value" type="input" /></td>
                </tr>
                <tr>
                    <td><label>Score: </label></td><td><input id="score" type="input" /></td>
                </tr>
            </table>
        </div>
        <div id="buttonArea"><button id="sendData">Send Data</button></div>

        <style>
            #dataArea {
                margin-top: 15px;
                margin-bottom: 15px;
            }

        </style>
    </body>
</html>

処理をするスクリプトからは、Subscribeや接続後のコールバックメソッドは取り除いて、とりあえずPublishできるように作っています。

script.js
var data = {
    messages: []
};

var body = {};

$(document).ready(function () {
    $("#sendData").click(function () {
      console.log('connect');
      var id = $('#id').val();
      var value = $('#value').val();
      var score = $('#score').val();

      if (id == "" || value  == "" || score == "") {
          alert("Please fill each input boxes.");
          return;
      }

      if (!$.isNumeric(value) || !$.isNumeric(score)) {
          alert("Please input number for input field and score field.");
          return;
      }

      body.id = id;
      body.value = value;
      body.score = score;
      body.timestamp = new Date().toISOString();

      send(JSON.stringify(body)); 
    });
  });
 :
 :
 // 以降、ほぼ記事のコピー

これで、画面は完成です。こんな感じのシンプルな画面ですが、OKとします。

スクリーンショット 2018-01-03 16.48.55.png

適当に値を入れて送信すれば、DynamoDBにきちんと保存されるようになりました。これでデバイス側のシミュレーションもできる様になりましたね。

スクリーンショット 2018-01-03 16.49.21.png

監視データの取得はひとまず任意のタイミングで直近のものを取る

現状画面下部の超見辛いボタンを押すと、body部に指定した範囲の時刻で検索を行ってくれます。ただ、毎回ソースを変更しないと検索範囲が指定できないのはイケてないので、直近1分のデータを取得できるようなボタンを設置します。

監視データ取得後の処理を共通化

とりあえず現状の監視データ取得後の処理を共通化して、直近1分のデータ取得後でも共通メソッドとして呼び出せるようにします。

    var applyData = function(result) {
        var resultObjArray = new Array();
        // Add success callback code here.
        var resultJson = JSON.parse(result.data);
        :
        :
        :

こんな感じで変数化して、呼び出しはシンプルに.then(applyData)とします。

ついでにマスタデータをDynamoDBから取得してきたら、その値をグローバル変数のbodyに適用するようにinitメソッドを修正します。これでいちいちデバイスのIDを指定しなくても、登録されているデバイスは監視対象(データ取得対象)とすることができます。

    function init(table) {
        for (var i = 0; i < table.length; i++) {
            // body update
            body.id.push(table[i].id);

取得する側の処理としては、実際にイベントが開始した時刻とその1分前をbody部に適用してあげる必要があります。

    $("#get1minData").click(function () {
        var now = new Date();
        var before1min = new Date(now);
        before1min.setMinutes(before1min.getMinutes() - 1);

        body.time_from = before1min.toISOString();
        body.time_to = now.toISOString();
        apigClient.rootPost(params, body, additionalParams)
            .then(applyData)
            .catch(function (result) {
                // Add error callback code here.
                alert("Failed");
                alert(JSON.stringify(result));
            });
    });

これで直近1分のデータが取得できるようになりました。

iot-demo.gif

ちなみに今回はマウスコントロールのローテーション部分をtrueにしていましたが(controls.noRotate = true;)、これをfalseにすれば、マウス操作で画面を回転させることも可能になります。

まとめ

Three.js、AWS IoTなどを使って監視っぽいデモを作ることができました。MQTTを使ってデータのやり取りをできるベースも出来上がっているので、AWS IoTとのリアルタイム通信&画面更新、さらには各監視データ用コンポーネントにグラフなんかを表示できるようになったらかっこいいですね。それはまた今度、ということで。

成果物

画面
https://github.com/kojiisd/aws-iot-demo

デバイス
https://github.com/kojiisd/aws-iot-demo-device

Lambda側集約処理
https://github.com/kojiisd/lambda-dynamodb-aggregator