はじめに

これは ドリコム Advent Calendar 2020 の10日目です。
9日目は 広井淳貴 さんによる、 引っ越しから始める Jenkins Configuration as Code生活 です。
こんにちは、ドリコム Advent Calendarでは毎年Raspberry Piネタで寄稿している廣田です。
今年も例に漏れずRaspberry Piで何か作っていこうと思います!

今回は温度・湿度を測定し、必要に応じてLINE Bot経由で取得したり、適正値を外れると通知してくれるようなシステムを作りたいと思います。
というのも、私事ですが今年の7月からハムスターを飼い始めました。
名前はみたらしちゃんです(めーっちゃかわいい☆)!
生後約1ヶ月
ハムスターはもともと乾燥地域の生き物らしく、温暖湿潤な日本の気候は実は適していません。
東京は夏は暑く冬は寒いので、人類ですら辛いのに体の小さいハムスターが辛くないわけがありません。
そこで、気温をモニタリングしてハムスターにとって適温でない場合に知らせてもらおうと思ったわけです。

システム構成

構成図はざっくり上記のイメージです。

  • Raspberry Piが気温・湿度を取得
  • 規定の温度以下・以上であればLINE Messaging APIを使って家族のアカウントに通知

がやりたいことの肝になります。
それだけならGCP使ってWebhook用意する必要ないですし、なんならLINE Messaging APIでなくLINE Notifyでいいのですが、例えば出先などで任意のタイミングで温度・湿度を確認したいケースもあるので、ユーザーからのメッセージに応答できるMessaging APIを選択しました。

Messaging APIから応答メッセージを返すにはWebhookの設定が必要で、今回はGoogle Cloud Functionsを選択しました。
LINE公式のドキュメントだとHerokuを使って簡単にサーバーを準備する手順が記されており、それも手軽で良さそうだったのですが、後述するようにスプレッドシートをAPIで操作する必要があり、そのためにはGCPのプロジェクトを新たに用意する必要があったのでAPIの置き場も合わせました。

LINE Messaging APIからCloud Functions上のAPIを叩きデータを取得するのですが、自宅のローカルネットワーク上のRaspberry Piに直接アクセスすることはできないのでスプレッドシートをデータベース的に使っています。
全体の実装物は下記の通りです。

  • Raspberry Piでは cron で毎分温度・湿度を取得しスプレッドシートに記録する
  • この時温度が規定の範囲外であればLINE Messaging APIで家族のアカウントに通知する
  • Cloud Functionsでスプレッドシートから温度・湿度を取得しLINE Messaging APIで送信できるAPIを用意する
  • LINE Messaging APIのWebhookに登録しておき、Botアカウントに問い合わせがあれば返答する

最後に、構成図にはないですが今回使用した温湿度センサーの紹介です。
今回はDHT22 / AM2302というセンサーを利用しました。
素の状態だとユニバーサル基盤やブレッドボードに抵抗と一緒に配線する必要がありコンパクトにならないので、既に基盤につけられているものを選びました。
Raspberry PiのGPIOを介して電源もデータも取ることができます。

温湿度が計測できるセンサーでRaspberry Piに取り付けられるものはいくつか種類があるのですが、

  • 温度・湿度ともに測定誤差が市販の温湿度計と比べて遜色ないこと
  • 電子工作関連の部品やOSSライブラリで有名なAdafruitがPythonで利用できるライブラリを提供してくれていたこと

が決め手となりました。
AM2302というのはDHT22と同じ仕様の別名のようですが、今回の記事では以降DHT22で統一します。

構築

ハードウェア

Raspberry PiにDHT22を接続していきます。
DHT22からはピンが3つ出ており、それぞれ+, OUT, -と表記があるのでわかりやすいですね。
Raspberry Pi側のGPIOピンのどこに繋げば良いかは公式のデータシートを参考にします。
DHT22の+が電源なので 5V power と記載のある2番のピン、 -がGNDなので Ground の6番のピン、OUTが出力なので GPIO の12番のピンという具合に選びます。
+は3.3Vかどちらか悩みましたが、秋月電子さんが公開されているデータシートrecommended supply voltage is 5V とあったので5Vにしました。
細かい注意としては、ピンの物理的な番号とGPIOの入力としての番号がずれていることでしょうか。
今回で言うと物理的には12番目のピンに繋ぎましたが、 GPIO 18 という名前がついているようにソフト側から入力を取る際には18番ピンを指定する必要があります。
ピンの指定に関しては今回使ったライブラリがよしなにやってくれるので、後ほど紹介します。

ここまでできたらOSをインストールしたmicroSDカードを挿して電源を入れていきます。
去年の記事ではArch LinuxをホストOSとしてゲストOSを立てられるようにしたので今年もそれを使いまわそうと思ったのですが、家の物理的な制約だったり、ハムスターが部屋を散歩するときにケーブルを齧る恐れなどを考慮すると無線接続が必須でした。
ホストOSからゲストOSのネットワークのブリッジはWi-Fiではできないので、今回は新たにRaspberry Pi OS Liteを焼いたSDを用意しました。

Rasberry PiのOSインストール、特にRaspberry Pi標準のRasbperry Pi OS Liteのインストールについては山ほど情報があるので当記事では割愛させてもらいます。
macOS, Windows, Ubuntuの方はRaspberry Pi Imagerを使うと特に悩むこともなくOSを焼くことができます。
1つだけTipsとして、OSを焼いたmicroSDカードのルートディレクトリに ssh という名前のファイルを置いておくと初回起動時からssh接続が有効になります。
Raspberry Pi自体をモニタやキーボードに繋ぐのもめんどくさいという僕のような方におすすめです。
ssh して、 raspi-config でOSの設定を済ませて、 sudo apt update sudo apt upgrade すれば大体の準備は終わりです。

各種スクリプトの作成

ここから実際に用意したスクリプトを紹介していきますが、全体はGithubのwatch_hamsリポジトリにあげているのでこちらをご覧ください(僕はUBISOFTのWATCH_DOGSシリーズが大好きなので勢いでリポジトリ名を決めました)。
以下では各スクリプトから抜粋しながら動作を紹介していきます。

温度・湿度計測スクリプトの準備

Adafruitのpython用のライブラリAdafruit_CircuitPython_DHTを使います。
Adafruit_CircuitPython_DHT内部で使われているGPIOのライブラリ libgpiod はRaspberry Pi OSには標準では入っていないので、これも合わせてインストールします。
$ sudo pip3 install adafruit-circuitpython-dht
$ sudo apt install libgpiod2
ライブラリを用いて温度・湿度を取得する最小限のコードは下記です。
import adafruit_dht
from board import D18

dht_device = adafruit_dht.DHT22(D18)
temp = dht_device.temperature
humidity = dht_device.humidity
ピン番号やデバイス名の定数が定義されているので直感的で使いやすいですね。
注意としては、ライブラリのバグでRaspberry PiのGPIO 4のピンからは入力が受け取れないことがあります。
ライブラリのリポジトリにissueが立っているのでこちらを参照ください。
ひとまず、GPIO 18ピンからは入力が取れるという意見が多かったです。

GCPのプロジェクトを用意してスプレッドシートにデータを書き込む

Raspberry Piで取得したデータをスプレッドシートに書き込むためにはGCPのGoogle Sheets APIが必要になります。
なのでGCPのプロジェクトを用意してAPIを有効にしていきましょう。
APIを有効にしたら、認証情報を追加という項目からサービスアカウントを選んでアカウントを作成していきます。
アカウント名だけ決めたらオプションの設定は特にせず作成してしまって問題ありませんが、アカウント作成後にアカウントの詳細ページから鍵を追加という項目を選択し、新しい鍵を作成しておきます。
ここでjsonファイルがダウンロードできますが、これは後ほどスクリプトからスプレッドシートへアクセスする際の認証に使うのでRaspberry Piにscpなどであげておきましょう。
アカウントが作れたら、サービスアカウント用のIDが入ったメールアドレスが発行されるので、新規のスプレッドシートを作成し、通常のGoogleアカウントと同様の手順でサービスアカウントのメールアドレスに共有します。

スプレッドシートには手動で、A1〜D1セルに timestamp temp humidity notified とヘッダーを追加しておきます。
後ほどスクリプトからデータにアクセスしやすくするためです。

スプレッドシートの準備とサービスアカウントへの共有ができたらスクリプトからのアクセスを試していきます。
Pythonからスプレッドシートを操作するには gspread というデファクトっぽいOSSがあったのでこちらを活用させてもらいます。
Google謹製のライブラリもあったのですが、こちらは書き込み時のPOSTリクエストを作る際にURLやリクエストボディに同じ値をいくつか設定しないといけなかったりして使い心地がよくありませんでした…
pip3でgspreadをインストールするとユーザーのホームディレクトリに .config/gspread/ というディレクトリが追加されるので、ここに先のサービスアカウントで鍵を追加したときに落としたjsonファイルを置いておくとgspreadからサービスアカウントを介してスプレッドシートにアクセスできるようになります。
スプレッドシートの取得から、温度・湿度の書き込みは以下のコードになります。
import os
import gspread from datetime
import datetime

# https://docs.google.com/spreadsheets/d/xxxx/edit#gid=0 の xxxx の部分を環境変数で指定
spreadsheet_id = os.getenv('SPREADSHEET_ID')
gc = gspread.service_account()
sh = gc.open_by_key(spreadsheet_id)
sheet = sh.sheet1
values = sheet.get_all_records()
sheet.update(f'A{len(values) + 2}:C', # ヘッダー行 + 既存レコードの行数の次の行に書き込む
             [[str(datetime.now()), temp, humidity]])
温度・湿度の取得は省略しています。
先程スプレッドシートに手動でヘッダーを追加しましたが、 get_all_records() はヘッダーをキーとしたdictを返してくれる便利関数です。
notified に関してはMessaging APIで通知を送るときに立てるフラグなので、今はスルーしてください。

Messaging APIで通知を送る

最後に、温度が規定値以外であればMessaging APIで通知を送る部分を加えていきます。

実装に入る前に、LINE Developersで開発者アカウントを作成します。
既にLINEアカウントを持っている場合はそのアカウントでログインするだけなので非常に楽です!
LINEの開発者アカウントを作るのは今回が初めてだったので、提供者を表すプロバイダーをまずは作成しました。
その後チャネルというものを作りますが、これがLINE Messaging APIのクライアント、いわゆる公式アカウントに当たります。
チャネルを作成したら管理画面に遷移するので、LINE Messaging APIのタブから Channel access token を控えておきます。

Messaging API用のチャネルが作成できたら実装に移っていきます。
ハムスターにとっての適温とは一般的に20℃〜26℃と言われているので、これを外れる場合には通知します。
Messaging APIについてはLINE公式のPython用SDKline-bot-sdk-pythonを用いました。
from linebot import LineBotApi
from linebot.models import TextSendMessage

ALERT_TEMP_MIN = 20
ALERT_TEMP_MAX = 26

# 先に控えた Channel access token を環境変数で指定
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')
line_user_ids = os.getenv('LINE_USER_IDS').split(',')
line_bot_api = LineBotApi(channel_access_token)

if temp < ALERT_TEMP_MIN or ALERT_TEMP_MAX < temp:
line_bot_api.multicast(
    line_user_ids,
    TextSendMessage(text=f'気温が{round(temp, 1)}℃になっています')
)
メッセージの通知自体は multicast のみなので非常に簡単です。
ただし、ここでメッセージを送るユーザーID(≠LINE ID)を指定する必要があります。
このユーザーIDはMessaging APIに登録したWebhookのURLへリクエストが来た際のリクエストボディからしかわからないので、動作確認は後にして次はWebhookを準備していきます。

Cloud FunctionsでWebhookを作成

先に準備したGCPのプロジェクトに戻り、検索窓などからCloud Functionsを選択します。
関数のランタイムはNode.jsやgoなども選択できますが、Webhookからもスプレッドシートを参照するので、先のコードを使いまわせるようにPythonを選択しました。
関数を作成のボタンを押すと関数名の入力に移りますが、ここで決めた関数名がWebhookのエンドポイントとなります。
今回は先のline-bot-sdk-pythonに含まれるWebhookのexamplesにあるecho-botに合わせて callback にしました。
トリガーのタイプはHTTPで、今回はMessaging APIから叩くので「未認証の呼び出しを許可」を選択しておきます。
また、後ほど使うのでランタイム環境変数に LINE_CHANNEL_SECRETLINE_CHANNEL_ACCESS_TOKEN をあらかじめ設定しておきます。
アクセストークンは先に使用した通りで、 LINE_CHANNEL_SECRET にはLINEのチャネルの管理画面にあるBasic Settingsタブ内の Channel secret を使います。
それ以外の詳細設定はデフォルトのままで構いませんが、うっかりGCPに課金されていたということがないように念の為メモリは最低の128 MiB、最大インスタンス数には1を設定しておきました。

Webhookのスクリプトは先のecho-botを参考に書いていきます。
echo-botではflaskを用いて POST /callback へのルーティングとハンドリングが書かれていますが、Cloud FunctionsではトリガーのURLが叩かれるとエントリポイントに指定された関数が呼び出される仕組みなので必要ありません。
そういったコードを省略していき、echo-botがCloud Functions上で動作する最小限は下記になります。
import os

from flask import abort
from linebot import (
    LineBotApi, WebhookParser
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,
)

channel_secret = os.getenv('LINE_CHANNEL_SECRET')
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')
line_bot_api = LineBotApi(channel_access_token)
parser = WebhookParser(channel_secret)

def callback(request):
    signature = request.headers['X-Line-Signature']
    body = request.get_data(as_text=True)
    try:
        events = parser.parse(body, signature)
    except InvalidSignatureError:
        abort(400)

    # for logging request contents
    print(events)

    for event in events:
        if not isinstance(event, MessageEvent):
        continue
    if not isinstance(event.message, TextMessage):
        continue

    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=event.message.text)
    )

return 'OK'
元のecho-botと比べると大分シンプルになりました。
ここで、単純にコードを削除しただけでなく、Cloud Functionsではリクエストボディは callback 関数の引数として渡せることに注意してください。
また、ユーザーIDをCloud Functionsのログから調べるために print(evnts) を追加しています。
いくつか外部ライブラリをimportしていますが、必要な外部ライブラリはインラインエディタにある requirements.txt に追加すると使えるようになります。
ただ、Cloud Functionsのpythonランタイムはflaskで動いているので、今回は line-bot-sdk だけでOKです。
上記のコードをCloud Functionsのインラインエディタに入力し、エントリポイントに callback を指定してデプロイすればWebhookを受けられる準備が整います。

デプロイが完了するとCloud Functionsの画面に作成した関数の一覧が表示されるので、作成した callback を選択し詳細を確認します。
トリガーのタブからURLがコピーできるので、これをLINEのチャネルの管理画面にあるMessaging APIタブからWebhookに設定します。
設定すると Verify ボタンが押せるようになるので、これを押すとWebhookヘリクエストが飛んで、ステータスコード200が返るかを簡単に確認できます。

Webhookが動いていることがわかったら、同じくMessaging APIタブのQRコードからLINEの友達追加ができるので、チャネルとのトークを開いて適当なメッセージを送ってみます。
スクリプトが正しく動いていれば、テキストメッセージを送るとそのままのメッセージが返ってくるはずです。
このとき、Cloud FunctionsのログからユーザーIDを確認しましょう。
WebhookのリクエストはLINE Developersのこちらのページに解説があり、 events 内の source から取れることがわかります。

Webhookから温度・湿度を返せるようにする

Raspberry Pi側のスクリプトで必要になったユーザーIDを調べるために準備し始めたWebhookでしたが、残りは少しなのでこのまま完成させてしまいましょう。
と言っても、 gspread を用いてスプレッドシートからレコードを取ってくるという点は変わらないので、コードはwatch_hamsリポジトリのmain.pyをご参照ください。
今回のコードでは、単純にユーザーから 気温湿度 という文字列を含むメッセージを受け取ったら、最近記録された気温・湿度を返すようにしています。
ポイントは、Raspberry Piでやったようにホームディレクトリ以下に認証ファイルを置くことができないので、インラインエディタからjsonファイルを追加し、そこにRaspberry Piに置いてある認証ファイルと同じ内容をコピペした上で、
gc = gspread.service_account(filename='service_account.json')
と呼び出すことです。
あとは、 requirements.txtgspread を、環境変数にスプレッドシートのIDを追加すれば準備完了です。

Raspberry Pi側のスクリプトを稼働させる

WebhookでユーザーIDがわかったので、Raspberry Pi側に準備したスクリプトの環境変数 LINE_USER_IDS を指定することができるようになりました。
ただ、今の状態では測定した際の気温が適正値でなければ常に通知を送ってしまいます。
通知を受け取ってすぐにエアコンを入れたとしても、1分後にすぐに気温が適正値に戻ることはないでしょう。
なので、測定した直前のレコードが適正値でなければ通知を送らないようにします。
かつ、それでは通知を見逃した際に適正値でないまま放置してしまうことになるので、10分間適正値を外れた状態が続くと再度通知を送るようにします。
この管理をするために必要なのがスプレッドシートに追加していた notified フラグです。
スクリプトの完成形はwatch_hamsリポジトリのwatch_hams.pyを参照ください。

スクリプトが完成したら crontab に登録します。
*/1 * * * * SPREADSHEET_ID='xxxx' LINE_CHANNEL_ACCESS_TOKEN='yyyy' LINE_USER_IDS=Otto-no-ID,Tsuma-no-ID python3 /home/pi/develop/watch_hams/watch_hams.py

最後に

今回はRaspberry Piで取得した温度・湿度をMessaging APIを使って通知するという単機能のLINE Botを作成してみました。
手順は多く感じたかもしれませんが、今回使用した技術はいずれも情報量が多くドキュメントもまとまっているので、変なハマりどころなどはなかったです。
これくらいの規模のLINE Botなら簡単に作れそうということが伝わればいいなと思います。
最後に、かわいいみたらしの写真を貼ってお別れしたいと思います。
生後約3ヶ月

ドリコムでは一緒に働くメンバーを募集しています!募集一覧はコチラを御覧ください!