小型マイコン M5Stamp S3 + InfluxDB + Grafana で作るIoT水槽モニタリングシステム
2023年12月04日 月曜日
CONTENTS
【IIJ 2023 TECHアドベントカレンダー 12/4の記事です】
はじめに
はじめまして。IoTビジネス事業部 技術部 プロダクトソリューション課に所属する加森です。
主にバックエンドとインフラのエンジニアとして、IoT関連サービスの開発および運用を行っています。
2 年前頃から水棲亀のアカセスジガメを飼い始めたのですが、成長とともに水槽が手狭になってきたため、最近、思い切って大きめの水槽を購入しました。
それに併せて砂利を敷いてみたり、新しいろ過システムを入れるなど、飼育環境に手を加えていたのですが、その延長で「水温、気温や日光浴のタイミングを可視化したら健康管理にいいんじゃないか?」と思い始め、この記事に至ります。
要件
ざっくりやりたいことを前述しましたが、要件をまとめると、このような感じです。
- 水温と気温の両方を記録できること
- 亀がバスキングスポット(日光浴のスペース)に居たか否かを記録できること
- 記録したデータを視覚的に表示でき、亀にとって適温でなくなった場合に通知ができること
- 水槽の見た目を損なわないこと
設計
概要図
測定の仕組みとデータやクエリの流れを示した図です。詳細については、順番に後述します。
センサー
センサー部分については、安価かつコンパクトなM5Stamp S3という小型マイコンボードに各種センサー素子を実装することにします。
M5Stackシリーズでは、温度の測定を行うためのセンサーユニットが販売されています。
それらはI2C経由でデジタル値の温度を直接読み取ることができるため、実装がかなり楽になるのですが、今回はDIYを楽しむためにあえて販売されているセンサーユニットを使わず、自作してみたいと思います。
“亀がバスキングスポットに居たか否かを記録”については、距離センサーで検知することにします。
亀は日光浴中、ほぼ静止状態であり、人感センサーでは状態を検知できないため、バスキングスポットの上部から距離を測定して検知することを試みます。
まとめると、これらのパーツを実装し、以下の動作をさせることで実現できそうです。
- 各センサー素子(サーミスタおよび距離センサー)にかかる電圧を読み取る
- 読み取った電圧から、温度と距離を算出する
- 算出した値をAPI ProxyにUDPで投げ続ける(将来的にバッテリー駆動を想定して、通信を短時間にしたい)
メトリクス収集基盤
測定値を可視化するための基盤側については、エンジニアブログの過去の記事でも何度か紹介されているGrafanaを使うことにします。
私も業務でよく使用しますが、見た目や機能など、細かく調整ができるところが特徴です。
Grafanaが対応しているデータソースはいくつかありますが、今回は時系列DBであるInfluxDBを使って、測定値を貯めていきます。
まとめると、これらのコンポーネントを用意すれば実現できそうです。
- API Proxy
- M5Stamp S3から受信した UDPパケットのペイロードから測定値を読み取り、InfluxDBのAPIを叩く
- InfluxDB
- API経由でAPI Proxyから測定値を受け取り、保存する
- Grafana
- InfluxDBと連携して、測定値をダッシュボードに表示する
実装
センサー
温度センサー部分に使用するサーミスタは、温度によって電気抵抗が変化するという特性を持ちます。
一方で、M5Stamp S3が読み取ることのできる値は電圧値のみです。なので、抵抗を並列に接続して分圧回路を構成し、分圧の法則を用いることで電圧値から抵抗値を算出します。
距離センサーに関しては、信号線の電圧値を読み取るためにM5Stamp S3に直結すれば良いのですが、使用したセンサーの入出力電圧が5Vなのに対して、M5Stamp S3のADCは3.3Vまでしか受け付けません。
なので、こちらも分圧回路を構成し、出力電圧を3.3Vに減圧して、M5Stamp S3に電圧値を読み取らせます。
余談ですが、途中このことに気づかず誤って5Vのまま入力してしまい、アナログ入力を1つ破壊してしまいました。
出来上がった回路図と、完成品は以下の通りです。
次に、プログラムを書いていきます。
温度センサーに関しては算出に必要な定数がメーカーから公開されているのでそのまま実装できるのですが、距離センサーについては電圧値から距離を算出するための式を求めておく必要があります。
若干原始的ですが、距離センサーを壁に向かせ、5cmずつ距離を離しながら都度Excelに電圧値を控え、グラフに表示し、式を算出するという方法で算出しました。(今回は測定された距離に精度を求めてないので、雑にやっています)
電圧値から温度および距離を算出し、API Proxyにデータを送信する処理を実装したコードは以下の通りです。
main.cpp
#include <WiFi.h> #include <AsyncUDP.h> float readTempSensorValue(int vInPin); float readDistanceSensorValue(int vInPin); int connectWifi(); int postToProxy(); const int ANALOG_MAX = 4095; // アナログ入力の最大値 12 bit なので 2^12 - 1 = 4095 char* ssid = "***"; const char* password = "***"; AsyncUDP udp; void setup() { USBSerial.begin(115200); if (!USBSerial) { delay(1000); } USBSerial.println("[INFO] setup start"); int wl_status = connectWifi(); if (wl_status != WL_CONNECTED) { return; } if (!udp.connect(IPAddress(192,168,11,100), 5000)) { return; } USBSerial.println("[INFO]UDP connect done"); USBSerial.println("[INFO] setup done"); USBSerial.println("[INFO] loop start"); } void loop() { float temp1 = readTempSensorValue(5); float temp2 = readTempSensorValue(7); float distance1 = readDistanceSensorValue(1); char* message = ""; sprintf(message, "{ \"temp1\": %f, \"temp2\": %f, \"distance1\": %f }",temp1, temp2, distance1); USBSerial.println(message); udp.print(message); delay(1000); } int connectWifi() { int status = WiFi.begin(ssid, password); if ( status == WL_CONNECT_FAILED ) { USBSerial.println("[FATAL] WL_CONNECT_FAILED"); return WiFi.status(); } USBSerial.print("[INFO] Wi-Fi Connecting"); while (WiFi.status() != WL_CONNECTED) { delay(500); USBSerial.print("."); } USBSerial.println(""); USBSerial.println("[INFO] Wi-Fi connected"); USBSerial.print("[INFO] IP address: "); USBSerial.println(WiFi.localIP()); return WiFi.status(); } float readTempSensorValue(int vInPin) { const float vcc = 3.3; const float staticB = 3435; const float static0 = 273; const float t0 = 25; const float r2 = 10000; int rawValue = analogRead(vInPin); float mVoltage = ( (float)rawValue * vcc * 1000 ) / ANALOG_MAX; float voltage = mVoltage / 1000; float r1 = ( vcc - voltage ) / voltage * r2; float temp = 1 / ( log( r1 / r2 ) / staticB + ( 1 / ( t0 + static0 ) ) ) - static0; return temp; } float readDistanceSensorValue(int vInPin) { const float vcc = 3.3; int rawValue = analogRead(vInPin); float mVoltage = ((float)rawValue * vcc * 1000) / ANALOG_MAX; float voltage = mVoltage / 1000; float distance = 65908 / pow(mVoltage, 1.21); return distance; }
API Proxy
以下の通り、UDP/5000で待機し、前述のセンサーから送信されたデータをパースして、名前や受信時刻などを付与し、InfluxDBのAPIを叩くというシンプルなものです。
API部分については公式のクライアントライブラリが提供されており、InfluxDBのWebUIからサンプルコードを参照できるので、調べる手間も無く簡単に書けました。
proxy.rb
#!/usr/local/bin/ruby require "influxdb-client" require "socket" TOKEN = "*" ORG = "default" BUCKET = "Aquarium" MEASUREMENT = "Environment" HOSTNAME = "StampS3" ENDPOINT = "http://influxdb:8086/" LISTEN_PORT = 5000 def main client = InfluxDB2::Client.new(ENDPOINT, TOKEN, precision: InfluxDB2::WritePrecision::NANOSECOND, use_ssl: false) write_api = client.create_write_api Socket.udp_server_sockets(LISTEN_PORT) do |sockets| Socket.udp_server_loop_on(sockets) do |msg, _| write_api.write(data: generate_data(msg), bucket: BUCKET, org: ORG) end end end def generate_data(received_msg) json = JSON.parse(received_msg) hash = { name: MEASUREMENT, tags: { host: HOSTNAME }, fields: { temp1: json["temp1"], temp2: json["temp2"], distance1: json["distance1"] }, time: Time.now.utc } end main
Docker環境で動かすため、簡単ですがDockerfileも用意しておきます。
FROM ruby:latest RUN gem install influxdb-client
InfluxDBとGrafana を起動する
M5Stamp S3とAPI Proxyの実装を進めてきましたが、InfluxDBとGrafanaの構築はDockerのコンテナを起動するだけで完了するので一番簡単です。
以下のようにcompose.ymlを用意し、環境変数でUsernameとPassword、Organizationを指定し、測定値の格納先となるBucketを指定すれば初期設定は完了です。
併せてAPI Proxyのコンテナも追加しておきます。
compose.yml
services: influxdb: image: influxdb container_name: influxdb volumes: - influxdb-volume:/var/lib/influxdb2 - influxdb-volume:/etc/influxdb2 - ./influx-configs:/etc/influxdb2/influx-configs ports: - 8086:8086 environment: - DOCKER_INFLUXDB_INIT_MODE=setup - DOCKER_INFLUXDB_INIT_USERNAME=admin - DOCKER_INFLUXDB_INIT_PASSWORD=password - DOCKER_INFLUXDB_INIT_ORG=default - DOCKER_INFLUXDB_INIT_BUCKET=Aquarium - INFLUXDB_HTTP_FLUX_ENABLED=true grafana: image: grafana/grafana container_name: grafana ports: - 8080:3000 volumes: - grafana-volume:/var/lib/grafana depends_on: - influxdb environment: - GF_SERVER_ROOT_URL=http://localhost:8080 - GF_SECURITY_ADMIN_PASSWORD=admin api_proxy: build: ./api_proxy container_name: api_proxy ports: - 5000:5000/udp volumes: - ./api/proxy.rb:/app/proxy.rb command: /app/proxy.rb volumes: grafana-volume: influxdb-volume:
動作確認
docker compose up
でコンテナを起動し、M5Stamp S3を起動させれば測定値の収集が開始されます。
InfluxDBのWebUI にログインし、メニューからBucketsを選択すると、compose.ymlの環境変数で指定したBucket (Aquarium)が表示されます。
Bucketを開くとData Explorerの画面が表示されるので、そこからFilterで絞っていくと、以下の通りグラフが表示されます。
なお、右上の SAVE AS からダッシュボードのパネルを作成することができます。
こちらはパネルをダッシュボードに一通り並べてカスタマイズしてみた一例ですが、グラフだけでなくメーターも表示できます。
(参考までにですが、ダッシュボードには他にも散布図、ヒストグラム、ヒートマップ、テーブルなどを表示できます)
また、メーターに閾値と色を設定することができるなど、Grafanaほどではありませんが、ある程度のカスタマイズができます。
さらに、InfluxDBは閾値監視と死活監視の機能も持っています。
詳細は割愛しますが、軽く触ってみたところ、通知機能も持っているようで、それらを利用すればで前述した要件は満たせそうです。
ただ、今回はGrafanaで表示するというのが目標みたいなものなので、このまま続行します。
GrafanaとInfluxDBを連携する
ここまでで、InfluxDBへ測定値が記録されること確認できました。
次はGrafanaとInfluxDBを連携して、Grafanaのダッシュボードを作っていきます。
詳細な手順は割愛しますが、GrafanaはInfluxDB用のプラグインを備えているため、InfluxDB側でAPI Tokenを発行し、Data sourceを追加することで簡単に連携ができます。
ちなみに、クエリの種類はここで選択することができ、InfluxQLとFluxのどちらかを選択する必要があります。
Flux vs InfluxQLを見てわかる通り、InfluxQLはSQL、FluxはJavaScriptに似ているように見えます。
パネルをダッシュボードへ追加する際の様子は以下の通りです。今回、クエリの種類はFluxを選択しています。
アラートと通知を設定する
Grafanaはアラートと通知の機能を持っており、メール通知はもちろん、Slack、Discord、Teams、LINEでの通知をサポートしています。
今回は、LINE Notifyを使って通知の設定を行いましたので、その手順を簡単に記載します。
- ダッシュボードの任意のパネルの設定を開き、Alert ruleにクエリと条件、およびラベルを設定する
- LINE Notifyのマイページからアクセストークンを発行し、発行時に指定したグループにLINE Notifyを招待する
- LINE Notifyのアクセストークンを設定したContact Pointを作成する
- Notification Policyを作成し、ラベルとContact Pointを紐付ける
設定が完了し、アラートが発行されると、いつどのような変化が起きたのかをグラフ上で視覚的に見ることができます。
LINE Notifyからはこのような形式で通知されます。
(テストのため、センサーを5度の冷水に浸けています)
仕上げ
気温、水温とバスキングの状態が一目でわかるよう、ダッシュボードを仕上げていったものがこちらです。
過去から現在までの温度や距離を表示するためのグラフ、最新の温度を表示するためのメーターを配置している他、ヒートマップを表示することでいつバスキングをしていたか、視覚的に確認できるようにしています。
それに併せて、センサーを水槽にセットしています。
“水槽の見た目を損なわないこと”という要件を最初に記載しましたが、微妙な感じです。
ケーブルがあるのは仕方ないですが、距離センサーについては取り付けパーツを作成したほうがよさそうです。
安全性に関しては、サーミスタのケーブルの被覆が薄く、水に浸かっている水温センサーを亀がかじったりするのが心配なので、アクリルパイプを加工して保護しています。
終わりに
電子回路の設計からGrafanaの設定まで、マルチスタックに紹介させていただきましたが、いかがでしたでしょうか。
やってみて一番きつかったのはユニバーサル基板へのパーツの実装で、基盤にコンパクトさをもとめすぎたことと、クリップやスタンドを用意していなかったことを後悔しました。
実際に数日間運用していますが、水温は水中ヒーターが定温に保ってくれているので問題無い一方で、気温の方は明け方にかけて下がっているので対策が必要そうであるということが分かりました。
機能的には問題ないものの、距離センサーの取り付け方が雑なので、ワーキングスペースなどで3Dプリンタを借りて、専用の取り付け器具を作ってみたいと考えています。
Xのフォロー&条件付きツイートで、「IoT米」と「バリーくんシール」のセットを抽選でプレゼント!
応募期間は2023/12/01~2023/12/31まで。詳細はこちらをご覧ください。
今すぐポストするならこちら→ フォローもお忘れなく!