node-red-node-ui-vegaを使った各種センサーのグラフ表示


はじめに

node-red-dashboardはローコードで可視化が実現できて大変便利です。ただ、カスタマイズに限度があり、もう少し見栄えを工夫したいなと思った場合には別の選択肢を探す必要があります。
そこで、今回はnode-red-node-ui-vegaを使って、温度センサーや湿度センサーの時系列データを、見やすいグラフに表示したいと思います。

今回作るものと特徴

  • 時系列データを面グラフで表示します。その上にオーバレイで現在の値をテキストで表示します。
  • 現在の値と、過去にさかのぼっての傾向が一目で見られるので、温度や湿度、使用電力などの状況表示に向いていると思います。

前提条件と準備するもの

  • raspberry piなどの上でnode-redが動作している必要があります。
  • 各種センサーなどの出力をnode-redで受信できている必要があります(mqtt inノードなど)。msg.topicにセンサー名が、msg.payloadにデータ(数値)が逐次入力されている状況にしてください。今回はダミー用にランダム値を使います。
  • node-red-dashboard、node-red-node-ui-vegaをインストールしておいてください。

コード

[{"id":"ac8af886.bbe948","type":"group","z":"94708220.d0eb5","style":{"stroke":"#999999","stroke-opacity":"1","fill":"none","fill-opacity":"1","label":true,"label-position":"nw","color":"#a4a4a4"},"nodes":["955c3878.844be8","b74c823e.aa5ce","9ef8a37a.31179","d4ea53fa.e7e23","60f61e56.68a1b","70af5789.f5d108","f733dbce.de0068","58ef958a.58179c","33be807.702268","758c6f5f.5b78a","9ea1dbd0.8e2db8","9c47d0ef.37dbd","ad807d04.c3546"],"x":474,"y":59,"w":552,"h":382},{"id":"955c3878.844be8","type":"template","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"init vega template","field":"payload","fieldType":"msg","format":"json","syntax":"mustache","template":"{\n  \"title\" : {\"text\" : \"notitle\", \"fontSize\" : 12, \"fontWeight\" : \"normal\"},\n  \"width\" : 0,\n  \"height\" : 0,\n  \"autosize\" : {\"type\" : \"fit\", \"contains\": \"padding\"},\n  \"layer\": [\n    {\n        \"data\": {\n            \"values\": []\n        },\n        \"mark\": {\n            \"type\": \"area\", \"clip\" : \"true\", \"line\": {\"color\": \"lightgreen\"}, \"color\": \"lightgreen\"\n        },\n        \"encoding\": {\n            \"x\": {\"title\" : null, \"field\": \"date\",\"type\": \"temporal\"},\n            \"y\": {\"axis\" : {\"labelAngle\" : -90, \"labelBound\" : 1}, \"title\" : null, \"field\": \"data\",\"type\": \"quantitative\" , \"scale\" : {}}\n        }\n    },\n    {\n      \"mark\": {\"type\":\"text\", \"fontSize\" : 32},\n      \"encoding\": {\"text\": {\"field\": \"value\", \"type\": \"nominal\"}},\n      \"data\": {\"values\": [{\"value\": 0 }]}\n    }\n  ]\n}\n","output":"json","x":590,"y":260,"wires":[["b74c823e.aa5ce"]]},{"id":"b74c823e.aa5ce","type":"change","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"set some data to template","rules":[{"t":"set","p":"widgetsize","pt":"msg","to":"status.common.widgetsize","tot":"flow"},{"t":"set","p":"payload.title.text","pt":"msg","to":"status.title","tot":"msg"},{"t":"set","p":"payload.width","pt":"msg","to":"msg.status.size[0] * msg.widgetsize - 12","tot":"jsonata"},{"t":"set","p":"payload.height","pt":"msg","to":"msg.status.size[1] * msg.widgetsize - 12","tot":"jsonata"},{"t":"set","p":"payload.layer[0].data.values","pt":"msg","to":"store","tot":"msg"},{"t":"set","p":"payload.layer[1].data.values[0].value","pt":"msg","to":"msg.store[-1].data & msg.status.unit","tot":"jsonata"},{"t":"set","p":"payload.layer[0].encoding.y.scale.domain","pt":"msg","to":"status.range","tot":"msg"},{"t":"set","p":"payload.layer[0].mark.line.color","pt":"msg","to":"status.color","tot":"msg"},{"t":"set","p":"payload.layer[0].mark.color","pt":"msg","to":"status.color","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":810,"y":260,"wires":[["60f61e56.68a1b"]]},{"id":"9ef8a37a.31179","type":"ui_vega","z":"94708220.d0eb5","g":"ac8af886.bbe948","group":"c03f93be.f065c","name":"0","order":1,"width":3,"height":2,"vega":"","x":810,"y":320,"wires":[]},{"id":"d4ea53fa.e7e23","type":"function","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"push new data to msg.store","func":"if(typeof msg.payload === \"number\"){\n    msg.store.push({\"date\" : new Date(), \"data\" : msg.payload});\n    if(msg.store.length > msg.status.maxlength){ msg.store.shift() }\n}\nreturn msg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","x":880,"y":200,"wires":[["955c3878.844be8"]]},{"id":"60f61e56.68a1b","type":"switch","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"switch msg.status.no","property":"status.no","propertyType":"msg","rules":[{"t":"eq","v":"0","vt":"num"},{"t":"eq","v":"1","vt":"num"},{"t":"eq","v":"2","vt":"num"}],"checkall":"true","repair":false,"outputs":3,"x":600,"y":320,"wires":[["9ef8a37a.31179"],["70af5789.f5d108"],["f733dbce.de0068"]]},{"id":"70af5789.f5d108","type":"ui_vega","z":"94708220.d0eb5","g":"ac8af886.bbe948","group":"c03f93be.f065c","name":"1","order":2,"width":3,"height":2,"vega":"","x":810,"y":360,"wires":[]},{"id":"f733dbce.de0068","type":"ui_vega","z":"94708220.d0eb5","g":"ac8af886.bbe948","group":"c03f93be.f065c","name":"2","order":3,"width":3,"height":2,"vega":"","x":810,"y":400,"wires":[]},{"id":"58ef958a.58179c","type":"function","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"init msg.status and msg.store","func":"if(!flow.get(\"store\")[msg.topic]){\n    flow.get(\"store\")[msg.topic] = [];\n}\nmsg.status = flow.get(\"status\")[msg.topic];\nmsg.store = flow.get(\"store\")[msg.topic];\n\nreturn msg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","x":620,"y":200,"wires":[["d4ea53fa.e7e23"]]},{"id":"33be807.702268","type":"inject","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"初期化","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"str","x":570,"y":100,"wires":[["758c6f5f.5b78a"]]},{"id":"758c6f5f.5b78a","type":"template","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"set flow.status","field":"status","fieldType":"flow","format":"json","syntax":"mustache","template":"{\n    \"common\": {\n        \"widgetsize\": 60\n    },\n\t\"temp1\": {\n\t    \"no\" : 0, \n        \"maxlength\" : 1440,\n        \"unit\" : \"℃\",\n        \"range\" : [9,31],\n        \"size\" : [3,2],\n        \"color\" : \"lightgreen\",\n\t    \"title\" : \"温度(寝室)\"\n\t},\n\t\"temp2\": {\n\t    \"no\" : 1, \n        \"maxlength\" : 1440,\n        \"unit\" : \"℃\",\n        \"range\" : [9,31],\n        \"size\" : [3,2],\n        \"color\" : \"lightgreen\",\n\t    \"title\" : \"温度(リビング)\"\n\t},\n\t\"temp3\": {\n\t    \"no\" : 2, \n        \"maxlength\" : 1440,\n        \"unit\" : \"℃\",\n        \"range\" : [9,31],\n        \"size\" : [3,2],\n        \"color\" : \"lightgreen\",\n\t    \"title\" : \"温度(ベランダ)\"\n\t}\n}\n","output":"json","x":720,"y":100,"wires":[["ad807d04.c3546"]]},{"id":"9ea1dbd0.8e2db8","type":"inject","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"初期表示","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"status","payloadType":"flow","x":580,"y":140,"wires":[["9c47d0ef.37dbd"]]},{"id":"9c47d0ef.37dbd","type":"split","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"topic","x":710,"y":140,"wires":[["58ef958a.58179c"]]},{"id":"ad807d04.c3546","type":"change","z":"94708220.d0eb5","g":"ac8af886.bbe948","name":"","rules":[{"t":"set","p":"store","pt":"flow","to":"{}","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":890,"y":100,"wires":[[]]},{"id":"c03f93be.f065c","type":"ui_group","z":"","name":"Group 1","tab":"4f7da949.d0fd28","order":1,"disp":false,"width":"9","collapse":false},{"id":"4f7da949.d0fd28","type":"ui_tab","z":"","name":"Tab 3","icon":"dashboard","order":3,"disabled":false,"hidden":false}]

コードの解説

初期化用フロー

初期化用フローでは、2つのフロー変数を初期化します。初期化したいときにinjectノードのボタンをクリックして作動させます。

  • templateノードで初期設定(json)をフロー変数(flow.status)に書き込みます。
  • 初期設定は下記2つの部分から構成されています。
    • 共通部分(common):共通設定を定義します。
      • widgetsize: widgetの最小サイズを設定します。
    • センサーごとの定義: センサー名称をキーとしたオブジェクトで定義します。
      • no: センサーを識別する番号
      • maxlength: 保存するデータ数(この数を超えるデータは古いものから削除されます)
      • unit: 単位
      • range: y軸の範囲
      • size: widgetのサイズ
      • color: グラフの色
      • title: グラフのタイトル表示

  • changeノードでデータ保持用フロー変数(flow.store)を初期化します。

データ保持用フロー

データ保持用フローでは、入力されたデータを現在の時刻情報とともにフロー変数flow.storeに保存します。

  • functionノード(init msg.status and msg.store)で、センサー名(msg.topic)をキーに、センサーごとの設定情報をflow.statusから読み取ってmsg.statusに保存します。同様に、センサーごとの既存データをflow.storeから読み取ってmsg.storeに保存します。flow.storeに該当のセンサーデータがなければ、保存先の配列を初期化します。

  • functionノード(push new data to msg.store)では、入力されたデータを日付情報とともにmsg.storeに挿入します。maxlengthより長い場合には古いデータを削除します。

vega-lite定義用フロー

vega-lite定義用フローでは、まずvega-liteのテンプレートをmsg.payloadに定義し、そこにセンサーごとのデータや設定情報を埋め込みます。

  • まず、templateノード(init vega-template)で、vega-liteのテンプレートを定義します。
    • グローバルでtitle、width、height、autosizeを設定します。
    • layer[0]に面グラフを定義します。
    • layer[1]に表示させたいテキストを定義します。

  • 次に、changeノードでセンサーごとのデータや設定情報を埋め込みます。
  • JSONataを使うと簡潔に書けるので便利です。


vega-lite表示用フロー

  • vega-liteの定義をvegaノードに注入し、グラフを表示させます。

  • switchノードで、msg.status.noの値で表示先を分岐させます。

  • switchノードの先にそれぞれのvegaノードを接続し、グラフを表示させます。

応用

vega-lite定義はJSONなので、いろいろアレンジを加えることができます。例えば、温度によってグラフの色を変えたりなども簡単にできると思います。

おわりに

vega-liteは初めて使いましたが、文法が簡潔な割に自由度が高く、グラフ定義がjsonで扱えることからnode-redとの親和性も高いので、センサーデータのちょっとした可視化にうってつけだと感じました。まだまだいろんな表現ができると思いますので、これからもいろいろ試してみたいと思います。

参考資料