ラズパイで植物の自動給水システムを作る

12 min read読了の目安(約11500字

背景

前々からIoT家庭菜園に興味はあったものの、ベランダがなく、日当たりも悪い家に住んでいるためずっと先延ばしにしていました。
「生命力の強いミントぐらいなら、なんとかなるんじゃないか?」と思っていた時、たまたまM5Stackから新製品が出ました。

M5Stackで作り込んでも良かったのですが、1年ぐらいラズパイは触っておらず、ちょうどコンテストもあるのでラズパイ使ってみました。

ちなみに最初に買ったミントは、本腰入れる前に水のやりすぎで根腐れしてしまい、現在は水耕栽培で育てています。

出来上がったもの

大葉(青しそ)にポンプモジュールを設置しています。

  • 1時間に1回センサ値確認(8時〜23時)
    • Googleスプレッドに書き込み
    • 値が閾値を越えた場合は給水
    • 給水成功/失敗をLINE Message APIで通知
  • 稼働開始・終了+αの通知
    • 8時に稼働開始
    • 23時に稼働終了
    • 19時に「おつかれ」通知 (自分の仕事の定時お知らせ)
  • LINEチャットボット
    • メッセージに"元気"を含む場合、最終更新データを元に状態を回答
    • メッセージに"{名前}"を含む場合、適当に返事
    • その他メッセージに対して、ランダムに返事

必要なもの

  • ラズパイ一式 (今回は3B+を使用)
  • M5Stack用 水分測定センサ付き給水ポンプユニット
  • ADコンバータ (今回はADS1115)
  • ブレッドボード
  • ジャンパーピン
  • ピンソケット または Grove4ピンコネクタ
  • SORACOM SIM & USBドングル (屋外利用などWi-Fiがない場合。SSH接続も可)

https://www.amazon.co.jp/dp/B081RH1MJZ
https://www.switch-science.com/catalog/7093/
https://www.switch-science.com/catalog/6913/

回路

とりあえずブレボ配線です。

システム構成図

通常はAWSなどでクラウド側を作り込む人が大半だと思うのですが、GAS(Google Apps Scripts)をエンドポイントにしています。

GASはデプロイの度にエンドポイントが変わる仕様になってしまうのと、アクセス権の都合等で会社アカウントでは使えないことデメリットもあるのですが、無料で使えるので個人で使う分には充分と思っています。

通信にはWi-Fi以外にsim(SORACOMなど)の利用が可能です。

LINE Message API

以下のうち、LINE Developersコンソールでチャネルを作成するの手順を行う。

https://developers.line.biz/ja/docs/messaging-api/getting-started/

MessageタブのWebhook settingsにはGASのエンドポイントを後から設定。

Auto-reply messagesは以下のように設定。

コード

GAS

JavaScriptは不得意なので、マサカリは投げないようにお願いします。
デプロイ周りはGCPアカウントによって動作が違うっぽいのでこちらを参考にしてください。

https://hack-tnr.hatenablog.com/entry/2020/06/06/173558
var sheet_id = "*****";
//スプレッドのID。https://docs.google.com/spreadsheets/d/{この部分}/edit#gid=2037815417


//デバイスからのPOSTで実行される関数
function doPost(e) {

  //POSTのpayloadを格納
  var parsed_data;
  try {
    parsed_data = JSON.parse(e.postData.contents);
  } catch (_exception) {
    // JSON以外がPOSTされたら例外を返して終了
    return ContentService.createTextOutput("JSONデータの解釈が出来ません。: " + _exception.message);
  }

  var spread_file_object = SpreadsheetApp.openById(sheet_id);
  var sheet_name = "log";
  var sheet_object = spread_file_object.getSheetByName(sheet_name);

  if ('values' in parsed_data) {
    // ラズパイからのPost(値)

    // スプレッドに行を追記
    sheet_object.appendRow([parsed_data.values["datetime"], parsed_data.values["value"], parsed_data.values["status"]]);

    SpreadsheetApp.flush(); //描かなくても動く。一応、遅延対策。

    // 直近のデータをプロパティとして保持
    PropertiesService.getScriptProperties().setProperty("last_update", parsed_data.values["datetime"]);
    PropertiesService.getScriptProperties().setProperty("current_value", parsed_data.values["value"]);
    PropertiesService.getScriptProperties().setProperty("current_status", parsed_data.values["status"]);

    return ContentService.createTextOutput(JSON.stringify({ 'content': 'post ok' })).setMimeType(ContentService.MimeType.JSON);

  } else if ('message' in parsed_data) {
    // ラズパイからのPost(メッセージ)
    push_msg = [parsed_data.values["message"]];
    post_line_msg(push_msg, "push", "");

  } else {
    // LINEからのメッセージ時
    var posted_msg = JSON.parse(e.postData.contents).events[0].message.text;

    var replyToken = JSON.parse(e.postData.contents).events[0].replyToken;
    if (typeof replyToken === 'undefined') {
      return;
    }

    var reply_msg = create_reply_msg(posted_msg);
    post_line_msg(reply_msg, "reply", replyToken);
  }
}

function create_reply_msg(posted_msg) {

  var spread_file_object = SpreadsheetApp.openById(sheet_id);
  var sheet_name = "log";
  var sheet_object = spread_file_object.getSheetByName(sheet_name);

  var current_value = parseFloat(PropertiesService.getScriptProperties().getProperty("current_value"));
  var default_msg_list = ['はにゃ', 'にく たべたい', 'もふもふ したい', 'うぇーい', 'なんだろう そういうのやめてもらっていいですか?', 'たのしー', 'ねむねむ', 'もきゅ', 'チラッ', 'ほえー'];
  var reply_msg = 's';
  try {
    if (posted_msg.indexOf("元気") > -1) {
      if (current_value < 1.68) {
        reply_msg = 'げんきー';
      } else if (current_value < 1.70) {
        reply_msg = 'ぼちぼち';
      } else if (current_value < 1.78) {
        reply_msg = 'みず ちょーだい';
      } else {
        reply_msg = 'むりー';
      }
    } else if (posted_msg.indexOf("シソンヌ") > -1) {
      reply_msg = 'んー';
    } else {
      reply_msg = default_msg_list[Math.floor(Math.random() * default_msg_list.length)];
    }
  } catch (_exception) {
    reply_msg = _exception.message
  }
  return reply_msg;
}

function post_line_msg(msg, type, replyToken) {
  /*
  以下の`Your user ID`の値を使用
  https://developers.line.biz/console/channel/{チャンネルID}/basics
  */
  line_user_id = '*****';

  /*
  以下の`Channel access token`の値を使用
	- https://developers.line.biz/console/channel/{チャンネルID}/messaging-api
  */
  var channelKey = '*****';

  var messages = [{
    'type': 'text',
    'text': msg,
  }];

  if (type == "reply") {
    var url = 'https://api.line.me/v2/bot/message/reply';
    UrlFetchApp.fetch(url, {
      'headers': {
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'Bearer ' + channelKey,
      },
      'method': 'post',
      'payload': JSON.stringify({
        'replyToken': replyToken,
        'messages': messages,
      }),
    });

  } else if (type == "push") {
    var url = 'https://api.line.me/v2/bot/message/push';

    UrlFetchApp.fetch(url, {
      'headers': {
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'Bearer ' + channelKey,
      },
      'method': 'post',
      'payload': JSON.stringify({
        'to': line_user_id,
        'messages': messages,
      }),
    });

  }
  return ContentService.createTextOutput(JSON.stringify({ 'content': 'post ok' })).setMimeType(ContentService.MimeType.JSON);
}

デプロイを実行すると、以下のウェブアプリURLが発行されます。
これをLINE DevelopersのMessage API > Webhookと、PythonのPost先エンドポイントに設定します。

Python(ラズパイ)

コンフィグからGPIO、I2Cを有効にする手順については割愛。
pipはこれだけで足りるはず。。(あまりにも色々試しすぎてちょっと自信ない)
sudo pip3 install adafruit-circuitpython-ads1x15

スプレッドの書き込みはPythonライブラリ(gspread)を使ってもできますが、GoogleAPIのOAuthトークンが稀に'invalid_grant: Invalid grant: account not found'エラーを吐くので、GASにPostで逃げています。

ファイル名:waterpump_rpi.py

"""
ラズパイ側で制御するプログラム
"""
import json
import logging
import sys
import threading
import time
import os

import datetime
import csv
import os

import requests
import board
import busio
import adafruit_ads1x15.ads1015 as ADS
from adafruit_ads1x15.analog_in import AnalogIn
import RPi.GPIO as GPIO

logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logger = logging.getLogger(__name__)

# GPIO Pins
GPIO_PUMP_OUT_PIN = 27 #GPIO.BOARDではなくGPIO.BCMで定義されているため、13pinではなくGPIO27。

# Setup IO
# GPIO.setmode(GPIO.BOARD) # ライブラリ競合のためコメントアウト
GPIO.setup(GPIO_PUMP_OUT_PIN ,GPIO.OUT)


# Create the I2C bus
i2c = busio.I2C(board.SCL, board.SDA)

# Create the ADC object using the I2C bus
ads = ADS.ADS1015(i2c)

# Create single-ended input on channel 0
moist = AnalogIn(ads, ADS.P0)

# 水やり閾値
MOIST_THRESHOLD=1.750

GAS_ENDPOINT="*****"

def main():        
    time_sta = time.time()

    first_moist_voltage=moist.voltage

    recordtime = '{0:%Y-%m-%d %H:%M:%S.%f}'.format(datetime.datetime.now())
    data = [recordtime , "{:>5.3f}".format(moist.voltage),'']        
    status=''

    # ログとスプレッドに保存
    _append_log()
    _append_ss()

    while moist.voltage > MOIST_THRESHOLD:
        # 水が閾値以上なら給水開始
        pump_on_off(1) 
        time.sleep(5)

        # # 10秒たってもいっぱいにならない場合(水容器が空の場合)
        if (time.time()-time_sta) > 15.0:
            pump_on_off(0) 
            status='給水NG'
            msg='おみず のみたいの!!'
            _append_log(status)
            _append_ss(status)

            _send_line_msg(msg)
            break

    if  first_moist_voltage -moist.voltage > 0.1 :
        # 最初の水量から増えた(給水があった場合)
        # 給水ストップ
        pump_on_off(0)       
        status='給水OK'
        msg='げぷー'

        _append_log(status)
        _append_ss(status)

        _send_line_msg(msg)     

def _append_log(status=''):
    # ローカルにログ保存
    recordtime = '{0:%Y-%m-%d %H:%M:%S.%f}'.format(datetime.datetime.now())
    data = [recordtime , "{:>5.3f}".format(moist.voltage),status]
    
    with open('/home/pi/water_pump/log.csv', 'a') as f:
        writer = csv.writer(f)
        writer.writerow(data)
    f.close()

def _append_ss(status=''):
    # スプレッドにログ保存
    recordtime = '{0:%Y-%m-%d %H:%M:%S.%f}'.format(datetime.datetime.now())
    values = {"datetime":recordtime,
                "value":"{:>5.3f}".format(moist.voltage),
                "status":status}
    payload ={"values": values}
    response = requests.post(GAS_ENDPOINT,json=payload)
    # GAS側のリダイレクト制御面倒なので入れてない

def _send_line_msg(msg):
    payload ={"message": msg}
    # GAS経由でPushメッセージを送信
    response = requests.post(GAS_ENDPOINT,json=payload)
    # GAS側のリダイレクト制御面倒なので入れてない

def pump_on_off(val):
    if val==1:
        # 給水する
        GPIO.output(GPIO_PUMP_OUT_PIN,True)  
        time.sleep(5)
    else:
        GPIO.output(GPIO_PUMP_OUT_PIN,False)  

if __name__ == '__main__':
    try:
        main()
    finally:
        GPIO.cleanup()

死活監視というほど大したものではないですが、ラズパイの通信が不安定なこともあって、おまけ的にラズパイ=> LineMessage APIのプログラムも入れています。(構成図に含まず)
以下が追加で必要です。
sudo pip3 install line-bot-sdk
sudo pip3 install flask

ファイル名:rpi_message.py

import sys
import json

from linebot import (LineBotApi, WebhookHandler)
from linebot.models import (MessageEvent, TextMessage, TextSendMessage)
from linebot.exceptions import (LineBotApiError, InvalidSignatureError)

LINE_CHANNEL_ACCESS_TOKEN = "*****"
LINE_USER_ID = '*****'


def main(message_type=""):
    if message_type=='morning':
        msg="おはー"
    elif message_type=='night':
        msg="おつかれー"
    elif message_type=='goodnight':
        msg="おやすー"
    else:
        msg="にゃーん"
    _send_line_msg(msg)

def _send_line_msg(msg):
    line_bot_api = LineBotApi(channel_access_token=LINE_CHANNEL_ACCESS_TOKEN )
    line_bot_api.push_message(LINE_USER_ID, TextSendMessage(text=msg))

    return {
        'statusCode': 200,
        'body': json.dumps('ok', ensure_ascii=False)
    }


if __name__ == '__main__':
    if len(sys.argv)>1:
        main(sys.argv[1])
    else:
        main()

その他

SORACOM sim設定

屋外用途などWi-Fiを使用しない場合に利用します。
我が家のラズパイのように、Wi-Fiが調子悪い場合はもちろん屋内使用も可能です。

こちらの手順に沿って設定します。

https://users.soracom.io/ja-jp/guides/devices/general/raspberry-pi-dongle/

SSH接続手順はこちら。userはラズパイのユーザー(piなど)に読み替えてください。

https://users.soracom.io/ja-jp/docs/napter/login-with-ssh/

ラズパイのシェル

わざわざ書くほどの大したものではないですが

#!/bin/bash
cd /home/pi/water_pump
python3 waterpump_rpi.py

定期実行(crontab -e)

1行目が8-23時まで1時間おきにシェルを実行するCron設定です。

01 8-23/1 * * * /home/pi/water_pump/waterpump.sh

00 19 * * * python3 /home/pi/water_pump/rpi_message.py 'night'
00 08 * * * python3 /home/pi/water_pump/rpi_message.py 'morning'
00 23 * * * python3 /home/pi/water_pump/rpi_message.py 'goodnight'

おまけ

2021/5/27 「SORACOM UG Online #5」にて、こちらの作品のLTをやらせていただきました。
GASとwaterpump_rpi.pyのうち、ラズパイ=>LINEの処理が変更になっています。

https://www.slideshare.net/ssuser68f293/m5stack-248618527

感想

今回の作品を作るにあたり、植物に名前をつけてチャットボット化したことで愛着が持てたのがよかったです。
(まぁ、葉っぱむしって食べますけど。)