l12a

白ウサギを追え

BME280 + obniz + GAE + Slack で空気のモニタリングをする

在宅ワークが中心の生活になったことで、家の環境を快適にしたい気持ちが半分、電子工作への興味半分で、室内の空気をモニタリングする仕組みを構築しました。
自宅で使うお遊び程度の道具なので、基本的に「動けばいい」でやっています。拙い点は目を瞑ってください。

作ったもの

15分に一回、センサーを使って気温・湿度・気圧を測定し、結果をサーバに保存。さらに Slack へ結果を通知します。

コンポーネントの解説

システムを構成する主要なコンポーネントは以下の通りです。

BME280

温度・湿度・圧力センサです。 基盤に実装されたモジュールの状態で数百円から購入することができます。

似た製品として、圧力センサのないBMP280もあります。

obniz

obniz はクラウドに特化した IoT 開発の仕組みです。

一見した印象は arduino に近いマイコンボードですが、obniz が指すものは単にボードだけではなく、
ボードにインストールされる obniz OS や、そこに接続される各種デバイスを制御する SDK や、プログラムを配置・実行するクラウド環境を含めた統合的な仕組みのことです。

obniz Board 1Y - クラウドにつながったEaaS開発ボード

https://amzn.to/2W9whb8

使用したボードは obniz board 1Y です。
旧世代のモデルと比べるとスリープ機能が加わっていて、消費電力を抑えたまま長時間待機することが可能です。
スリープからの復帰はプログラム制御でき、時間経過や本体のスイッチ操作など任意のタイミングで行えます。

Google App Engine

GCP のプロダクトの一つです。

フルマネージド型のサーバーレスなプラットフォーム上で、高度なスケーラビリティを備えたアプリケーションを構築します。

だそうです。どういう意味でしょうかね。

その他の GCP プロダクトと統合するための SDK が提供されていて、規定のプログラム言語を使ってアプリケーションを構築できます。

インフラの管理面をあまり気にかけることなくアプリケーション開発に注力できることが魅力だと思います。

今回は Go を使って API を開発し、Datastore にデータの永続化を行いました。

slack

もはや説明不要ですかね。いわゆるチャットコミュニケーションツールです。業務やコミュニティユースで幅広く使われています。

僕は仕事はもちろん家庭のコミュニケーションも slack グループを作成して使っています。

slack 上で動作する様々なアプリがサードパーティ含めて多数存在し、また自作することもできます。

今回は、Web hook として利用可能なボット機能を使いました。

アーキテクチャ

作成したシステムの全体像は以下の図の通りです。

f:id:lnly:20200430100537p:plain
空気モニタリングのアーキテクチャ

次の手順で作成しました。

  1. BME280 と obniz の回路作成
  2. ケースの工作
  3. GAE に API を作成
  4. GAE から Slack へのメッセージ送信機能作成
  5. obniz から API へデータを送信するプログラムの作成

BME280 と obniz の回路作成

BME280 は HiLetgo というブランドを選びました。

https://amzn.to/2VR2N2S

この製品はピンヘッダが付属していますが、自分ではんだ付けする必要があります。
また、obniz のドキュメントで紹介されている製品とは別のものなので、ピンの順番と数が違いますので注意して工作します。

f:id:lnly:20200430102402j:plain
ピンヘッダをはんだ付けしたBME280

BME280 のピンと、 obniz のソケットのアサインは以下の通りに行います。

BME280 obniz
VCC 0
GND 2
SCL 5
SDA 4
CSB 3
SDO 6

obniz への接続は、ドキュメントによると直結させずプルアップ抵抗を実装するように指示がありましたので、その通りに行います。

BME280 | JS Parts Library | obniz

1KΩの抵抗を2つ用意して、SCL と SDA それぞれから VCC へ配線するようにはんだ付けをします。

f:id:lnly:20200430103056j:plain
ジャンパワイヤ上に無理やり付けたプルアップ抵抗

ピンヘッダやユニバーサル基盤がなかったので、ジャンパワイヤの被覆を途中で剥いて直接はんだ付けしています。真似しないほうがいいと思います。
この後抵抗をマスキングテープで絶縁するなどの誤魔化し加工をして完了です。

配線できたら、BME280 側の各ピンからジャンパワイヤを使って obniz に接続します。

f:id:lnly:20200430103503j:plain
BME280 と接続した obniz Board 1Y

ハードウェアの工作は以上です。

ケースの工作

埃をかぶったりしないようケースに入れたいと思い、いらなくなったトランプのケースを加工しました。

f:id:lnly:20200430104441j:plain
穴を開けたトランプのケース

電源コードを通す穴と、BME280のセンサー部分を外気に露出させるための穴を開けました。

GAE 上に API を作成

obniz からデータを送信するための API を Go で実装します。
基本的に以前書いた gin で構築する方法を用いますので、本記事では特筆すべき点だけを記載します。

前回の記事と併せて読んでください。

Go で作る SPA 用バックエンド - l12a

ルーター

package router

import (
    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    "github.com/linlymatsumura/HouseMonitor/pkg/handler"
    "time"
)

func Build() *gin.Engine {
    router := gin.New()

    router.Use(cors.New(cors.Config{
        AllowMethods: []string{
            "POST",
            "OPTIONS",
        },
        AllowHeaders: []string{
            "Access-Control-Allow-Headers",
            "Authorization",
        },
        AllowOrigins: []string{
            "http://obniz.io",
            "https://obniz.io",
        },
        MaxAge: 24 * time.Hour,
    }))

    basicAuthUser := gin.Accounts{
        "BASIC_AUTH_USER": "BASIC_AUTH_PASSWORD",
    }

    authorized := router.Group("/", gin.BasicAuth(basicAuthUser))

    authorized.POST("/api/v1.0.0/air_condition_report", handler.PostAirConditionReport)

    return router
}

ルーターでは CORS の設定を追加する必要があります。
これは obniz がデータをポストしてくる際 obniz.io のドメインから送ってくるためです。

なんとなくBasic認証もかけておきました。

ハンドラ

package handler

import (
    "fmt"
    "github.com/gin-gonic/gin"
    "github.com/linlymatsumura/HouseMonitor/pkg/model/airConditionReport"
    "github.com/linlymatsumura/HouseMonitor/pkg/util/env"
    "math"
    "net/http"
    "strconv"
)

func PostAirConditionReport(c *gin.Context) {
    err := c.Request.ParseForm()
    if err != nil {
        c.String(http.StatusBadRequest, "error")
        return
    }

    temperature, err := strconv.ParseFloat(c.Request.Form["temperature"][0], 64)
    if err != nil {
        c.String(http.StatusBadRequest, "error")
        return
    }

    humidity, err := strconv.ParseFloat(c.Request.Form["humidity"][0], 64)
    if err != nil {
        c.String(http.StatusBadRequest, "error")
        return
    }

    pressure, err := strconv.ParseFloat(c.Request.Form["pressure"][0], 64)
    if err != nil {
        c.String(http.StatusBadRequest, "error")
        return
    }

    device := c.Request.Form["deviceName"][0]

    secret := c.Request.Form["key"][0]
    if secret != env.Get().AirConditionReportSecret {
        c.String(http.StatusBadRequest, "error")
        return
    }

    err = airConditionReport.Save(c, temperature, humidity, pressure, device)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": err,
        })
        return
    }

    c.String(http.StatusOK, "ok")
}

リクエストパラメータは、

  • 気温
  • 湿度
  • 気圧

の各数値の他、

を取ります。
これは別の部屋にもセンサーを設置した場合に識別できるようにすることを想定しているためです。

その他、簡単ですがあらかじめ決めておいたシークレットキーを取るようにしました。
保存する前にキーの照合をしてから書き込むようにします。

GAE から Slack へのメッセージ送信機能作成

Slack へのメッセージ送信は Incoming Webhook を使います。

slack.com

公式ドキュメントに詳しく記載がありますので、設定手順は割愛します。
設定を終えると、Slack へメッセージ送信するための URL を得られますので、それを使います。

package slackUtil

import (
    "bytes"
    "fmt"
    "net/http"
)

func SendMessage(content string) error {
    jsonStr := fmt.Sprintf(`{"text":"%v"}`, content)

    req, err := http.NewRequest(
        "POST",
        "SLACK_WEBHOOK_URL",
        bytes.NewBuffer([]byte(jsonStr)),
    )
    if err != nil {
        return err
    }

    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    return err
}

上記のようなユーティリティを作ってハンドラの最後で呼び出します。

package handler

import (
    "fmt"
    "github.com/gin-gonic/gin"
    "github.com/linlymatsumura/HouseMonitor/pkg/model/airConditionReport"
    "github.com/linlymatsumura/HouseMonitor/pkg/util/env"
    "github.com/linlymatsumura/HouseMonitor/pkg/util/slackUtil"
    "math"
    "net/http"
    "strconv"
)

func PostAirConditionReport(c *gin.Context) {

    // 中略

    err = airConditionReport.Save(c, temperature, humidity, pressure, device)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": err,
        })
        return
    }



    // slack へ送信

        formatTemperature := roundUp(temperature, 1)
    formatHumidity := roundUp(humidity, 0)
    formatPressure := roundUp(pressure, 0)
    message := fmt.Sprintf("🌡 環境モニタリングレポート(%v)\n  - 気温: %v ℃\n  - 湿度: %v %\n  - 気圧: %v hPa",
        device,
        formatTemperature,
        formatHumidity,
        formatPressure,
    )

    slackUtil.SendMessage(c, message)

    c.String(http.StatusOK, "ok")
}

func roundUp(num, places float64) float64 {
    shift := math.Pow(10, places)
    return roundUpInt(num*shift) / shift
}


func roundUpInt(num float64) float64 {
    t := math.Trunc(num)
    return t + math.Copysign(1, num)
}

obniz から API へデータを送信するプログラムの作成

obniz のプログラムは HTML/Javascript を使って作成します。
obniz がオンラインのとき、ブラウザ経由でプログラムにアクセスし、デバイスを操作したりできます。

また別の方法として、obniz クラウドが提供するサーバレスイベントによる実行方法があります。
あらかじめ決められたスケジュールや、 obniz がオンラインになったことなどをトリガーとして、特定のプログラムを実行できます。

今回は以下のプログラムを作成しました。

  1. obniz がオンラインになった時
  2. センサーを起動して値を取得
  3. API へデータを送る
  4. 完了後、15分間スリープモードにする

オンラインになったイベントをトリガーにしてこのプログラムを起動することで、15分おきに起動・測定・保存を繰り返す仕組みにしました。

<html>
<head>
  <script src="https://obniz.io/js/jquery-3.2.1.min.js"></script>
  <script src="https://unpkg.com/obniz@3.4.0-beta.0/obniz.js" crossorigin="anonymous"></script>
</head>
<body>

<div id="obniz-debug"></div>
<h1>環境モニタリング</h1>
<div id="print"></div>

<script>
var obniz = new Obniz("OBNIZ_ID", { access_token:"OBNIZ_ACCESS_TOKEN" });
obniz.onconnect = async function () {
  
  var bme280 = obniz.wired("BME280", {vio:0, vcore:1, gnd:2, csb:3, sdi: 4, sck: 5, sdo:6 });
  await bme280.applyCalibration();
  const val = await bme280.getAllWait();
 
  obniz.display.clear();
  obniz.display.print('temp: ' + val.temperature);
  obniz.display.print('humi: ' + val.humidity);
  obniz.display.print('pres: ' + val.pressure);
  
  $.ajax({
    url: 'API_URL',
    type: 'post',
    beforeSend: function (xhr) {
      xhr.setRequestHeader ("Authorization", "Basic " + btoa("BAIC_AUTH_USER: BAIC_AUTH_PASSWORD"));
    },
    data: {
      temperature: val.temperature,
      humidity: val.humidity,
      pressure:val.pressure,
      key: 'SECRET_KEY',
      deviceName: 'LivingRoom'
    }
  })
  .done(function () {
    obniz.display.clear();
    obniz.display.print('success');
    obniz.sleepMinute(15);
  })
  .fail(function () {
    obniz.display.clear();
    obniz.display.print('faild');
    obniz.sleepMinute(15);
  })

}

</script>
</body>
</html>

懐かしい jQuery.ajax() ですね。
obniz の開発者コンソールでサーバレスイベントの設定を行います。

f:id:lnly:20200430115111p:plain
obniz 開発者コンソールでサーバレスイベントの設定

完成

f:id:lnly:20200430115833j:plain
完成した空気モニタリングデバイス

これでデバイスをコンセントに接続すれば、定期的にレポートが取得できるようになりました。
併置したアナログの温度湿度計と近似した値が取れているので、概ね正確なデータが取れていると思います。

永続化したデータをグラフィカルに表示したり、特定の閾値の時だけメンションを出すような機能追加をやりたいと思っています。

データを可視化してみると「ご飯を炊くと湿度が上がる」というような、
考えてみれば当然のことではありながら、通常意識することのない環境変化に気付くなどの発見が面白いですよ。