丁寧に手を抜く

頑張らない努力

Raspberry Piとgo言語で部屋のコンディションを記録してグラフ化した

自分のプロダクトばかり作っていると技術の幅も狭まってしまうので、定期的に趣味がてら題材を見つけて普段使わない技術に触れている。

自分にとってベストな部屋のコンディションが知りたい

今回は兼ねてからやりたかった、自分の部屋の温度や湿度などのコンディションを数分ごとに記録してグラフで可視化すること。 体調と空気の質は関連が深い。 気圧が低いと頭痛を起こす人もいるし、ジメジメしていると汗が気になって仕方がないという人もいるだろう。 そういう関係性を客観的に調べられたら、自分にとって最もパフォーマンスが出る条件がわかる。 まずはともあれ記録をとってグラフ化するところから始めようというわけだ。

使用機材

f:id:craftzdog:20180213103545j:plain

まだ届いていないけど、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のコンソールからデータが正しく記録されていることを確認する:

f:id:craftzdog:20180904154019p:plain

グラフ描画webフロントエンドを作る

デスクトップとモバイルのChromeで動けばよしとする。 最近はアロー関数が動くのでメソッドチェーンが書きやすい。 シンプルなプログラムなのでbabelもwebpackも使わず、Vanilla JSで行く。

ひとまず完成図がこちら:

f:id:craftzdog:20180904154436p:plain

とてもいいんじゃないでしょうか!

グラフは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&apos;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の表示
  • 体調が良かった日、悪かった日の記録
  • アラート機能 (気圧が低すぎるとか)

などを追加していきたい。