Raspberry Piとgo言語で部屋のコンディションを記録してグラフ化した
部屋のコンディションをRaspberry Piとセンサー使って、3分ごとに記録してグラフ化するところまで出来た!
— TAKUYA🐾個人開発で食うノウハウを書く (@craftzdog) September 3, 2018
今留守にしているので、外気とちゃんと連動してるのが分かる。
これで自分の調子がいい時・悪い時の部屋のコンディションが調べられる☺️
もうすぐ二酸化炭素濃度センサーも届くので楽しみ💪 pic.twitter.com/WIoihOcZPw
自分のプロダクトばかり作っていると技術の幅も狭まってしまうので、定期的に趣味がてら題材を見つけて普段使わない技術に触れている。
自分にとってベストな部屋のコンディションが知りたい
今回は兼ねてからやりたかった、自分の部屋の温度や湿度などのコンディションを数分ごとに記録してグラフで可視化すること。 体調と空気の質は関連が深い。 気圧が低いと頭痛を起こす人もいるし、ジメジメしていると汗が気になって仕方がないという人もいるだろう。 そういう関係性を客観的に調べられたら、自分にとって最もパフォーマンスが出る条件がわかる。 まずはともあれ記録をとってグラフ化するところから始めようというわけだ。
使用機材
- Raspberry Pi 3
- ANAVI Infrared pHAT センサーキット
- 温度 (℃)
- 気圧 (hPa)
- 湿度 (%rh)
- 明度 (Lux)
まだ届いていないけど、CO2センサーをAliExpressで買ったので、それも追々組み込む予定。
Go言語でセンサーデータを取得してCloud Firestoreに格納する
Go言語は前々から気になっていた言語。 やはりチュートリアルをやっただけではしっくりこないので、こういう実用的な題材で組むと一気に理解が進む。
Cloud Firestoreも気になっていたGoogleのクラウド向けデータベース。 PouchDBみたいにオフライン同期に対応している点が面白い。 ちょっと複雑なクエリやComposite Indexingにも対応している。 まだBeta段階のようだけど、完成度は申し分なく、問題なく使える。
今回のプロジェクトを通して、この2つの技術と仲良くなりたい。
センサーデータの取得
ANAVIのセンサーキットにはサンプル集がGitHubで公開されている。 これをそのまんま使って、実行結果をGolangによる簡単な文字列処理を経由してデータを取り出す。 例えばBMP180センサーのデータは以下のように取得する:
package sensors // Temperature and Pressure sensor import ( "log" "os/exec" "strings" "strconv" ) func GetTempAndPressure() (temperature float64, pressure float64, err error) { out, err := exec.Command("/home/pi/anavi-examples/sensors/BMP180/c/BMP180").Output() if err != nil { log.Fatal(err) } s := string(out[:len(out)]) lines := strings.Split(s, "\n") lineTemp := lines[1] linePress := lines[2] tempStr := strings.Split(lineTemp, ": ")[1] temperature, err = strconv.ParseFloat(tempStr[0:len(tempStr)-2], 32) pressStr := strings.Split(linePress, ": ")[1] pressure, err = strconv.ParseFloat(pressStr[0:len(pressStr)-4], 32) return }
Firestoreへの格納
各センサーの取得スクリプトが書けたら、以下のようなmainスクリプトを書いてFirestoreに記録する:
package main import ( "./sensors" "log" "time" "context" firebase "firebase.google.com/go" "google.golang.org/api/option" ) type RoomData struct { temperature float64 pressure float64 humidity float64 light float64 } func recordData (roomData *RoomData) { ctx := context.Background() opt := option.WithCredentialsFile("<YOUR-SERVICE-ACCOUNT-KEY.json>") app, err := firebase.NewApp(ctx, nil, opt) client, err := app.Firestore(ctx) if err != nil { log.Fatal(err) } collection := client.Collection("conditions") _, _, err = collection.Add(ctx, map[string]interface{}{ "createdAt": time.Now(), "humidity": roomData.humidity, "light": roomData.light, "pressure": roomData.pressure, "temperature": roomData.temperature, }) if err != nil { log.Fatalf("Failed adding alovelace: %v", err) } } func main () { log.Println("Start capturing my room confitions") temperature1, humidity, _ := sensors.GetTempAndHumid() log.Printf("Temperature: %f C\n", temperature1) log.Printf("Humidity: %f %%rh\n", humidity) light, _ := sensors.GetLight() log.Printf("Light: %f Lux\n", light) temperature2, pressure, _ := sensors.GetTempAndPressure() log.Printf("Temperature: %f C\n", temperature2) log.Printf("Pressure: %f %%rh\n", pressure) data := RoomData{ temperature: temperature1, pressure: pressure, humidity: humidity, light: light, } recordData(&data) log.Println("Finished recording data!") }
あとは3分ごとにこいつを実行するようにcronで設定すれば完成。 Firebaseのコンソールからデータが正しく記録されていることを確認する:
グラフ描画webフロントエンドを作る
デスクトップとモバイルのChromeで動けばよしとする。 最近はアロー関数が動くのでメソッドチェーンが書きやすい。 シンプルなプログラムなのでbabelもwebpackも使わず、Vanilla JSで行く。
ひとまず完成図がこちら:
とてもいいんじゃないでしょうか!
グラフはhighcharts.jsを使用。 Mixpanelとかでも使われているグラフ描画ライブラリ。 とても簡単にハイクオリティなグラフが書けるのでオススメ。 非商用利用なら無料。
ソースは以下のような感じ:
HTML:
<!doctype html> <html> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> <title>Craftzdog's Room Conditions</title> <script src="lib/highcharts.js"></script> <script src="lib/dark-unica.js"></script> <script src="lib/moment.min.js"></script> <script src="lib/moment-timezone-with-data-2012-2022.min.js"></script> <script src="lib/firebase-app.js"></script> <script src="lib/firebase-auth.js"></script> <script src="lib/firebase-firestore.js"></script> <link rel="stylesheet" href="lib/semantic.min.css" /> <link rel="stylesheet" href="lib/semantic-icon.css" /> <link rel="stylesheet" href="./app.css" /> </head> <body> <div class="ui container"> <h1 class="align center">My Room Conditions <i class="home icon"></i></h1> <div class="ui stackable grid"> <div id="temperature" class="eight wide column" style="width:100%; height:400px;"></div> <div id="humidity" class="eight wide column" style="width:100%; height:400px;"></div> <div id="pressure" class="eight wide column" style="width:100%; height:400px;"></div> <div id="light" class="eight wide column" style="width:100%; height:400px;"></div> </div> </div> <script src="./app.js"></script> <script> retrieveData().then(data => { renderChart(data) }) </script> </body> </html>
JS:
// Initialize Firebase var config = { apiKey: '****', authDomain: '***.firebaseapp.com', databaseURL: 'https://***.firebaseio.com', projectId: '***', storageBucket: '***.appspot.com', messagingSenderId: '***' } firebase.initializeApp(config) var db = firebase.firestore() const settings = { timestampsInSnapshots: true } db.settings(settings) db.enablePersistence().catch(function(err) { if (err.code == 'failed-precondition') { } else if (err.code == 'unimplemented') { } console.error('Failed to enable persistence:', err) }) window.retrieveData = function() { var conditions = db.collection('conditions') return conditions .orderBy('createdAt', 'desc') .limit(300) .get() .then(querySnapshot => { var items = [] querySnapshot.forEach(doc => { items.push(doc.data()) }) items = items.map(item => { return Object.assign({}, item, { temperature: item.temperature - 1.4, humidity: item.humidity + 8.2 }) }) return items.reverse() }) } window.renderChart = function(items) { const lastItem = items[items.length - 1] const basicOptions = { time: { timezone: 'Asia/Tokyo' }, xAxis: { type: 'datetime' }, legend: { layout: 'vertical', align: 'right', verticalAlign: 'middle' }, plotOptions: { series: { label: { connectorAllowed: false }, pointStart: 2010 } }, responsive: { rules: [ { condition: { maxWidth: 500 }, chartOptions: { legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom' } } } ] }, theme: { chart: { backgroundColor: { linearGradient: { x1: 0, x2: 1, y1: 0, y2: 1 }, stops: [[0, '#2a2a2b'], [1, '#2a2a2b']] } } } } Highcharts.theme.chart.backgroundColor = { linearGradient: { x1: 0, x2: 1, y1: 0, y2: 1 }, stops: [[0, '#2a2a2b'], [1, '#2a2a2b']] } Highcharts.setOptions(Highcharts.theme) Highcharts.chart( 'temperature', Object.assign({}, basicOptions, { title: { useHTML: true, text: '<i class="thermometer icon"></i> Temperature: ' + lastItem.temperature.toString().substr(0, 4) + ' ℃' }, yAxis: { title: { text: '℃' } }, series: [ { showInLegend: false, name: 'Temperature', data: items.map(item => [ item.createdAt.seconds * 1000, item.temperature ]) } ] }) ) Highcharts.chart( 'humidity', Object.assign({}, basicOptions, { title: { useHTML: true, text: '<i class="tint icon"></i> Humidity: ' + lastItem.humidity.toString().substr(0, 4) + ' %rh' }, yAxis: { title: { text: '%rh' } }, series: [ { showInLegend: false, name: 'Humidity', data: items.map(item => [ item.createdAt.seconds * 1000, item.humidity ]) } ] }) ) Highcharts.chart( 'pressure', Object.assign({}, basicOptions, { title: { useHTML: true, text: '<i class="sun icon"></i> Pressure: ' + Math.round(lastItem.pressure) + ' hPa' }, yAxis: { title: { text: 'hPa' } }, series: [ { showInLegend: false, name: 'Pressure', data: items.map(item => [ item.createdAt.seconds * 1000, item.pressure ]) } ] }) ) Highcharts.chart( 'light', Object.assign({}, basicOptions, { title: { useHTML: true, text: '<i class="lightbulb icon"></i> Light: ' + lastItem.light + ' lux' }, yAxis: { title: { text: 'Lux' } }, series: [ { showInLegend: false, name: 'Light', data: items.map(item => [item.createdAt.seconds * 1000, item.light]) } ] }) ) }
Future Work
今後はこれらのデータをもとにして、
- 一日の平均、max、minの表示
- 体調が良かった日、悪かった日の記録
- アラート機能 (気圧が低すぎるとか)
などを追加していきたい。