【IoT×農業】~自動化だけじゃない~Botによってリモート操作できるIoTシステムのモデル

2024/03/05

Discord IoT ラズパイ

  • B!

事前準備

今回のIoTシステムの構築に必要なものです。

  1. ラズパイのIPを固定する(推奨)
  2. NFSサーバーの設定
  3. Sambaサーバーの設定
  4. シェルスクリプトmount.shの作成
注意(推奨)

fstabを編集すると起動時に自動マウントできて便利ですが、今回は編集しません。

設定したストレージをマウントせずに起動しようとすると起動できなくなります。

また、fstabの記述ミスがあると起動できなくなり、復旧するのに時間を取られてしまいます。

fstabは編集せずに起動後に環境を整えてからシェルスクリプトを手動で実行する形をとります。

全体図

Raspberry Pi 4

  • DiscordBotサーバー
  • 各種センサーからデータを取得する
  • 共有ストレージにデータを送信する

Raspberry Pi zero

  • 共有ストレージ管理用
  • NFSサーバー
  • Sambaサーバー

DiscordBot(Raspberry Pi 4)

  • 共有ストレージのデータを読み取る
  • グラフや最新データを送信する

DiscordBotはユーザーからのコマンドを受け取り、それに応じた操作を可能にします。

単にラズパイで自動化するのではなく、DiscordBotにより ユーザーからの操作も受け付けることができるシステムが簡単に作れます。

NFSサーバー

ラズパイ同士のファイル共有に用います。

Raspberry Pi 4からRaspberry Pi zeroへのファイル送信を行います。

Raspberry Pi zeroに接続されたHDDのフォルダをRaspberry Pi 4に見せるイメージです。

逆にして設定してしまうとHDDの意味が全くない状態になってしまうので注意します。

Sambaを設定した後、フォルダのプロパティなどで容量を確認すると安心です。

Sambaサーバー

Raspberry Pi zeroに接続されたストレージ内のデータをWindowsやスマホなどからアクセスできるようにするために用います。

今回はデータの保存にGoogleDriveを使用しません。

容量の制限、認証にかかる時間などが原因で取得するデータ量を調整する必要があるためです。

その点、大容量のHDDを使えば制限から解放されます。

Sambaサーバーを用いることで、他のデバイスで各種センサーの元データを分析したり確認したりすることができます。

設定ファイル

IoTシステムを実行させるにあたって、事前に設定しなければならない値がいくつか存在します。

  • プログラムのループ間隔
  • 1回当たりの水量
  • 水やりのタイミング
  • 土壌湿度のトリガー感度

これらの値はプログラム上で変更可能ですが、今回は「config.csv」に記述します。

設定ファイルから値を読み込むようにすれば、DiscordBot経由でいつでも設定値を変更できるようになります。

config.csv
setting,value
loop_interval,900
volume_water,2
watering_interval,2
trigger,2

それぞれ次のように設定します。

  • loop_interval => 全体のループ間隔(s)
  • volume_water => 給水量(リレーのON時間(s))
  • watering_interval => 水やりの間隔(乾燥を検知して何回目のループで給水するか)
  • trigger => 土の乾燥を認める電圧値(voltage)

給水量は事前に何秒モーターをONにしたら何ml得られるかを調べておきます。

今回用いたポンプ(モーター)は3.3v駆動で2秒ONにすると50mlの水が得られたのでこれをもとに給水量を定義します。

各種センサー・モジュール

それぞれの用途とPythonのサンプルコードです。

注意

ここでのサンプルコードは完成した物から各センサー・モジュールと関連がある箇所を抜粋したものです。

サンプルコードのまま、動作を保証するものではありません。

温湿度センサー(DHT11)

温度と湿度の取得に用います。

植物の至適環境の範囲内であるかを監視するだけでなく、範囲外になった場合、通知するなどの機能を追加することができます。

サンプルコード
dht11.py
import time
import datetime
import dht11
import RPi.GPIO as GPIO
import csv
import pandas as pd

GPIO.setmode(GPIO.BCM)
instance = dht11.DHT11(pin=14)

try:
    while True:
        #設定ファイル1(CSV)の読み込み
        path_setting = 'hdd/data/config.csv'
        df_setting = pd.read_csv(path_setting)
        
        dt = datetime.datetime.now().replace(microsecond=0)
        unix = int(time.time())
        unix_15 = unix + int(df_setting['value'][0]) #設定ファイル(取得間隔)
        print('Start', dt)
        
        th_count = 0
        while True:
            dt_csv = datetime.date.today()
            
            #本日の記録ファイルが存在するかチェック
            path = 'hdd/data/data/' + str(dt_csv) + '.csv'
            is_file = os.path.isfile(path)
            
            #存在した時
            if is_file:
                pass
            #存在しなかったら新しく作る
            else:
                #print('none')
                with open(path, 'w') as f:
                    fieldnames  = ['DateTime', 'Temperature', 'Humidity', 'Cds', 'Moist', 'Moist_label', 'Watering']
                    writer = csv.DictWriter(f, fieldnames=fieldnames )
                    writer.writeheader()
                    print('created', str(dt_csv) + '.csv') 
            
            temphumi = instance.read()
            
            #取得成功時
            if temphumi.is_valid():
                u_time = int(time.time())
                s_time = unix_15 - u_time
                
                t = temphumi.temperature
                h = temphumi.humidity
                
                print("Temp: %-3.1f C" % t)
                print("Humi: %-3.1f %%" % h)
                break
                
            #取得失敗時
            else:
                #100回まで試みる
                th_count += 1
                if th_count == 100:
                    
                    #100回失敗したらスキップする
                    u_time = int(time.time())
                    s_time = unix_15 - u_time
                    t = 'NULL'
                    h = 'NULL'
                    print("Temp:", t)
                    print("Humi:", h)
                    break
                    
                print('error : ', th_count)
                time.sleep(1)

        time.sleep(s_time)

except KeyboardInterrupt:
    print("Cleanup")
    GPIO.cleanup()

土壌湿度センサー

水やりのタイミングを検知するために用います。

水やりのトリガーになるため、植物に合わせて乾燥状態からどれくらい時間が経過したら再度水やりを行うか設定します。

デジタル値を出力できる抵抗式でもいいのですが、電気分解による電極の腐食がかなり早く進んでしまったので静電容量式を用いることにします。

似たようなセンサーに「レインセンサー」があり、このセンサーも使用したかったのですが、同じく電気分解による電極の腐食と、電極の素材である銅が塩化銅になってしまうという問題があり、耐久性に乏しいので実装しませんでした。

抵抗式はデジタル・アナログ出力が可能で安価なため、動作確認用・短期使用であれば問題ありません。

ラズパイはアナログ入力に非対応のため、ADS1115によってA/D変換します。

サンプルコード
moist.py
from busio import I2C
import board
import time
import adafruit_ads1x15.ads1115 as ADS
from adafruit_ads1x15.analog_in import AnalogIn
import datetime
import RPi.GPIO as GPIO
import csv
import cv2
import pandas as pd

i2c = I2C(board.SCL, board.SDA)
ads = ADS.ADS1115(i2c)
channel0 = AnalogIn(ads, ADS.P0)

count = 0

try:
    while True:
        moist = channel0.voltage
        watering = ''
        if moist > int(df_setting['value'][3]): #設定ファイル(乾燥とする電圧値)
            print('DRY :', moist)
            moist_label = 'dry'
            count += 1
            if count == int(df_setting['value'][2]): #設定ファイル(水やりのタイミング)
                print('Wartering')
                watering = 'W'
                
                #リレーをONにして水を供給する
                relay = 17
                GPIO.setup(relay, GPIO.OUT)
                GPIO.output(relay, GPIO.LOW)
                time.sleep(2)
                GPIO.output(relay, GPIO.HIGH)
                
            else:
                pass                
            
        else:
            print('WET :', moist)
            moist_label = 'wet'

        time.sleep(s_time)

except KeyboardInterrupt:
    print("Cleanup")
    GPIO.cleanup()

リレー・水ポンプ

水ポンプの駆動に用います。

センサーからの情報に応じてON/OFFを切り替えます。

一回の水やりに消費する水量を決めておき、どれくらいの時間ONにすれば目標の水量に達するか事前に調べておく必要があります。

今回は秒数を指定することで目標の水量を得ようとしていますが、水量を設定してプログラム上でONにする秒数に変換する方が直観的で分かりやすいかもしれません。

WEBカメラ

一定の間隔で植物を撮影します。

温湿度などの環境データと共に記録され、植物の健康状態を遠隔で確認できるようにします。

サンプルコード
moist.py
import datetime
import cv2

try:
    while True:
        capture = cv2.VideoCapture(0)
        capture.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
        capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
        ret, frame = capture.read()
        dt = dt.strftime('%Y_%m_%d_%H_%M_%S')
        cv2.imwrite('hdd/data/pic/' + dt + '.png', frame)
        cv2.imwrite('hdd/data/latestdata/latest.png', frame)
        capture.release()

        time.sleep(s_time)

except KeyboardInterrupt:
    print("Cleanup")
    GPIO.cleanup()

記録ファイルの中身

画像はiPhoneのファイルアプリから開いた様子です。

15分間隔のはずですがsleepで規定しているため数秒ずつ遅れています。

冒頭で例に挙げた「config.csv」の設定値で動かしたものです。

乾燥状態(DRY)になると、設定通りの2回に1回のタイミングで水やりが行われ「Wataring」の行に「W」の印が入ります。

時間ズレ対策(sleep)

記録ファイルの時間は、sleep()で時間を規定しているため、時間が少しずつずれています。

そこでsleepではなく別の方法で休止処理を行います。

以下のプログラムを実行してどのくらいずれが改善されるか検証します。

test.py
import time

def pause(s):
    now = time.time()
    while (time.time() - now < s):
        pass

#while分を使って休止処理
now = 0
timeList_1 = []
for i in range(20000):
    now = time.time()
    pause(1)
    timeList_1.append(time.time()-now)
#平均を求める
print('PAUSE : ', sum(timeList_1)/len(timeList_1))

#sleepを使って休止処理
now = 0
timeList_2 = []
for i in range(20000):
    now = time.time()
    time.sleep(1)
    timeList_2.append(time.time()-now)
#平均を求める
print('SLEEP : ', sum(timeList_2)/len(timeList_2))

実行結果は次のようになりました。

PAUSE :  1.0003710204839706
SLEEP :  1.0011507595181466

sleepを使うよりもwhile分を用いた関数を定義して休止処理した方がより正確に時間を測れることが分かりました。

sleep()」ではCPUの利用権限を解放してしまい、復帰するときにラグが発生します。

対して「pause関数」では指定時間が経過するまで「while True」の処理が行われ続けることで、CPUの利用権限を解放することがないのでラグが発生しにくいようです。

しかし、問題点もあります。

whileが高速でループし続けるため、CPU使用率が高くなってしまうことです。

Raspberry Pi 3以降であれば問題になりにくいですが、Raspberry Pi Zeroでこの処理を行うとCPU使用率が80%以上の状態が持続してしまい、別の処理に影響が出る可能性があります。

今回は4Bを使っているので問題ありません。

実際のコードをsleep()からpause()に変更したときの記録ファイルを確認してみます。

右が「sleep()」使用時、左が「pause()」使用時です。

明らかに時間ずれが改善されています。

DiscordBotの機能

今回、DiscordBotには以下の役割を担ってもらいます。

  • データのグラフ化
  • 設定値変更
  • 最新データ取得
  • 全データのリストを取得
  • 写真を撮影する
  • 水をやる
  • HDDの容量の確認
  • データの削除

Botに任せることで間接的にラズパイを遠隔操作できることになり、便利です。

コントロールパネル

/control」のコマンドを送信することで以下のようなボタンの集合体を表示します。

ボタンを押すことで各機能を手動で実行することが可能です。

最新データの取得

センサーから取得した値と画像を送信します。

データリスト

いつのデータが保存されているかの確認に用いることができます。

HDDの容量

画像データを定期的に保存するため、容量は日々増え続けていきます。

保存データの整理・削除の目安としてHDDの容量を確認できます。

プログラム上で定期的にHDDの容量を確認して「何GB以上消費したときに通知する」のような機能をつけるとより安心できます。

設定値変更

今までの「/control」コマンドではなく、別の「/config」を送信することで次のようなモーダルウィンドウが表示されます。

変更したい場所だけ記述して送信することが可能です。

また、数値の全角記入や、数値以外の入力を防止するため、有効な数値以外のものが送信された場合は何も変更せずに自動でスキップされます。

これにより、プログラムがエラーで停止するのを防止できます。

グラフ化

まずどのデータをグラフ化したいか決めます。

関連のありそうな2つのデータを1つのグラフにまとめるようにします。

  • 温度と湿度
  • 温度とCds
  • 温度と土壌湿度

これらのデータをグラフ化するプログラムを作成します。

関連記事
サンプルコード

3つのグラフをfor文で作成し、それぞれWebhookで送信します。

graph.py
def csv_graph(f_serch):
    print('[INFO] <' + str(datetime.datetime.now().replace(microsecond=0)) + '> Graphing Data')
    graph = [['Temperature', 'Humidity'], ['Temperature', 'Cds'], ['Temperature', 'Moist']]
    d = 0
    g_label = ['TH', 'TC', 'TM']
    
    for i in graph:
        df = pd.read_csv(f_serch)
        fig = plt.figure()
        ax1 = fig.subplots()
        ax2 = ax1.twinx()
        x_time = pd.to_datetime(df['DateTime'])

        y_val1 = df[i[0]]
        y_val2 = df[i[1]]
        
        #画像の名前
        img_name = x_time[0].strftime('%Y_%m_%d_' + g_label[d])

        l_val1, l_val2 = i[0], i[1]
        ax1.set_ylabel(l_val1)
        ax1.plot(x_time, y_val1, color='red', label=i[0], marker='o', linestyle='dotted')
        ax1.legend(loc='upper right', bbox_to_anchor=(.5, 1.1))
        ax2.set_ylabel(l_val2)
        ax2.plot(x_time, y_val2, color='blue', label=i[1], marker='o', linestyle='dotted')
        ax2.legend(loc='upper left', bbox_to_anchor=(.5, 1.1))
        labels = ax1.get_xticklabels()
        
        #15分ごとにプロットする
        locator = mdates.MinuteLocator(15)
        #locator = mdates.AutoDateLocator()
        
        ax1.xaxis.set_major_locator(locator)
        ax2.xaxis.set_major_locator(locator)
        plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y/%m/%d %H:%M:%S'))
        plt.title('[' + img_name + ']',  y=-0.3)
        plt.setp(labels, rotation=45, fontsize=6)
        
        #画像の保存先
        plt.savefig('hdd/data/graph/[' + str(img_name) + '].png', bbox_inches='tight', dpi=300)
        
        img_path = 'hdd/data/graph/[' + str(img_name) + '].png'
        webhook_img(img_path) #DiscordWebhookで画像送信
        d += 1

実行結果

コマンド送信後、Discordのチャンネルに送られてくる画像です。

単位はそれぞれ次のようになっています。

  • 温度 => 度
  • 湿度 => %
  • Cds => voltage(max 3.3v)
  • 土壌湿度 => voltage(max 3.3v)


温度・湿度グラフ

エアコンで調節された室内で測定したため、温度と湿度には負の相関があります。

室外で測定すると季節の特徴が表れそうです。



温湿・Cdsグラフ

室外だと「太陽の光が届く」 => 「気温が高くなる」という関係が分かりやすくグラフに現れるはずです。

Cdsは明るくなると電圧が下がるため、温度と位相が逆になります。



温度・土壌湿度グラフ

土壌湿度センサは乾燥すると電圧が上昇します。

急激に乾燥しているのはセンサーを引き抜いたからです。

水やり(リモート)

追加で水やりを行いたいとき、自動化するだけだとリモート操作できないため手動で給水する必要があります。

DiscordBotによってリモート給水機能を実現できます。

watering.py
@discord.ui.button(label='Watering', row=0, style=discord.ButtonStyle.primary)
async def watering(self, button: discord.ui.Button, interaction: discord.Interaction):
    print('[INFO] <' + str(datetime.datetime.now().replace(microsecond=0)) + '> Watering')
    #await interaction.response.send_message("Watering...")
    await interaction.response.defer()
        
    relay = 17
    GPIO.setup(relay, GPIO.OUT)
    GPIO.output(relay, GPIO.LOW)
    time.sleep(int(df_setting['value'][1])) #設定ファイル読み込み
    GPIO.output(relay, GPIO.HIGH)

実行結果

まとめ

今回はIoTと農業を組み合わせたモデルを作成したので紹介しまた。

何らかの作業を自動化するというのはラズパイとセンサーのみで簡単に実現可能です。

しかし、ラズパイ主体のモデルになってしまうため、ユーザーからの指示・入力を受け付けるにはラズパイを触る必要がありました。

今回は「DiscordBot」のサービスを利用して可能な限り低いコスト・高い利便性・高い拡張性を備えたIoTシステムを作成することができました。

個々のユーザーが必要だと思った機能があれば、随時追加することができます。

ラズパイ×DiscordBotの組み合わせは他のIoTシステムにおいても応用可能です。

Ranking

Community

Search