🌱

ベランダ菜園のために IoT で水やりを最適化したい

2022/07/06に公開

はじまり

最近ベランダ菜園が意外と面白いことに気づいたので、水やりをどうにかして最適化できればいいなと思い、そのために土壌水分量を測りたくなりました。水分量を測るだけであればホームセンターや通販でもそのための装置が販売されていますが、せっかくなのでここで小型 IoT モジュールを使い、データをクラウド上に保存し、Discord や Slack に通知が飛ばすことができれば人間がいちいち様子を見に行かなくてもよく、水やりの最適化が図れると考えました。

現在入居している物件はベランダにコンセントがなく、ケーブルを引回す余裕もないので、思い切ってソーラーパネルによる太陽光発電のみに頼ることにしました。そのため Raspberry Pi のような電力消費が激しい IoT モジュールは避け、MicroPython を書き込んだ ESP32 を利用することにしました。この組み合わせは単純ですが、Python レベルで完結するのでやりたいことを簡単に実現できます。加えて、ディープスリープ機能で間欠動作させれば夜間や曇天でも長持ちするはずです。

シナリオ

  • センサー側
    1. ソーラーパネルで太陽光発電する
    2. モバイルバッテリーに充電する
    3. ディープスリープをはさみつつ定期的に ESP32 を起動
    4. ESP32 と土壌湿度計測モジュールを使用してプランターの土の水分量を計測する
    5. ESP32 で Wi-Fi を使用して AWS の API Gateway (Lambda) にデータを送信する
  • AWS 受信系
    1. データを受信した Lambda が S3 にデータを保存する
  • AWS 通知系
    1. EventBridge で定期的に Lambda を起動する
    2. Lambda が S3 の最新のデータを取得して、Discord や Slack で水分量を報告する

用意したもの

注意
オチが最後にあるのでこれらの資材を集めたからといってこのシステムを実現できるとは限りません。

ソーラーパネル(USB出力) + モバイルバッテリー

ソーラーパネルができるのは発電だけです。天気のよい日であればそれだけでも IoT モジュールを駆動できるかもしれませんが、自然環境下では夜間はもちろん雨天・曇天も考えられるので実用を考えると発電した電気を蓄えておかなければならず、充電池も必要になります。ただし単純にソーラーパネルと充電池を繋げばいいというものではなく、過充電・過放電にならないように充放電コントローラが必要になります。

かつては電子工作をする必要がありましたが、最近ではなんでも USB になっているので、ソーラーパネルも USB 出力タイプのものを用意すればモバイルバッテリーを組み合わせることで安全かつ簡単に太陽光発電と充電ができるようになります。ただし、それなりに値は張ります。

モバイルバッテリーは熱を持つと危険なので、ヒートシンクを放熱用シリコンで接着しました。それでも放熱が間に合わないようであれば、モバイルバッテリーに防水処理を施して水に浮かべることも考えましたがそこまでは必要なさそうです。

ESP32 Dev Kit V1

今回購入した ESP32 のボードは Aideepen 製ですが、あちこちのメーカーで類似品が販売されており、ピン数が一致していればほぼ仕様は同一であると思われます。

土壌湿度計測モジュール

Amazon でもいくつか販売されていますが、金属メッキが露出しているタイプは濡れているか濡れていないかぐらいの判断にしか使用できません。

ESP32 に MicroPython を書き込む

MicroPython のダウンロードと ESP32 への書き込み

公式リファレンスの ESP32 での MicroPython の始め方 を参考にすれば特に問題なく書き込めると思います。MicroPython ダウンロードページから、最新のファームウェアをダウンロードします。この記事執筆時点では v1.19.1 (2022-06-18) .bin が最新版です。

ファームウェアをダウンロードしたら pip3 や pipenv で esptool をインストールします。

$ pip3 install esptool

そうしたら ESP32 とパソコンを microUSB ケーブルで接続します。このとき新たに認識したシリアルポート番号を確認しておきます。Windows ではデバイスマネージャーで確認でき、COM1 や COM3 のような COM ポートとして認識します。UNIX 環境では /dev を確認しつつ抜き差しすればわかるかと思います。私の macOS 環境では、/dev/tty.usbserial-0001 として認識しました。

デバイスとしての認識が確認できたら次のコマンドでフラッシュを消去します。

$ esptool.py --port /dev/tty.usbserial-0001 erase_flash

デバイスによっては失敗することもあり、その場合は基板状の BOOT ボタンを押しっぱなしにして同じコマンドを実行してください。続けて MicroPython のファームウェアを書き込みます。

$ esptool.py --chip esp32 --port /dev/tty.usbserial-0001 write_flash -z 0x1000 esp32-20220618-v1.19.1.bin   

90 秒ほどで書き込みが完了します。

ESP32 のシリアルプロンプト (REPL) へのアクセス

書き込みが終了したら TeraTerm や screen コマンドで REPL にアクセスできます。転送速度は 115,200bps です。

$ screen /dev/tty.usbserial-0001 115200

screen を切断をするには Ctrl-Aky の順にタイプしてください。

MicroPython は起動直後で 110KB ほどのメモリしかありません。あまり大きなデータは保持できないので、いつもと異なるアルゴリズムを構築する必要があるかもしれません。

ESP32 に起動スクリプトを書き込む

MicroPython ではデバイス起動後に boot.py を実行したのち main.py を実行するようになっています。このファイルは open() で読み書きできるほか、公式の書き込みツールもあります。今回はこのツール pyboard.py を利用して main.py を書き込みました。

pyboard.py の動作には pyserial が必要になるので、pip3 や pipenv でインストールしたあとに、

$ python pyboard.py -d /dev/tty.usbserial-0001 -f ls

と打ち込むと ESP32 内に保存されているファイル一覧を表示できます。ファイルを書き込むには

$ python pyboard.py -d /dev/tty.usbserial-0001 -f cp main.py :main.py

とします。: なしがホスト側、: つきがデバイス側を示しています。

なお pyboard.py による操作は REPL にアクセスできる状況でないと受け付けないので、プログラムが実行されている場合には Ctrl-C などで打ち切って REPL を表示し、切断してから行うようにしてください。

pyboard.py がうまく動作しない場合

シリアルプロンプトが起動するまで少し時間がかかるので、うまく動作しない場合には遅延を入れるとうまく動くようになります。

--- pyboard_old.py      2022-06-27 22:02:53.000000000 +0900
+++ pyboard.py  2022-06-27 22:03:10.000000000 +0900
@@ -330,6 +330,7 @@ class Pyboard:
         while n > 0:
             self.serial.read(n)
             n = self.serial.inWaiting()
+        time.sleep(2)
 
         self.serial.write(b"\r\x01")  # ctrl-A: enter raw REPL

計測のための準備

土壌湿度計測モジュールのアナログ値を読む

ESP32 DEVKIT V1 - DOITの 30 ピンの画像と公式リファレンスを参考にジャンパワイヤで ESP32 と土壌湿度計測モジュールを接続します。ADC ブロック 2 は Wi-Fi と競合するので ADC ブロック 1 のピンに配線します。

ESP32 計測モジュール
3.3V - VCC
GND - GND
GPIO36 - AOUT

そうしたらシリアルプロンプトからアナログ値を読み込むためのコードを打ちこんでみます。

import machine

# GPIO36ピンのインスタンスを生成
pin36 = machine.Pin(36)

# ADCとして利用 (11dB減衰)
adc10 = machine.ADC(pin36, atten=machine.ADC.ATTN_11DB)

# アナログ値を取得
adc10.read()

リファレンスによれば ADC の基準電圧は 1.1V 前後になっているようです。それ以上の電圧値を読み取りたい場合には atten キーワード引数を指定して信号を減衰させる必要があるとのことです。デフォルトの減衰なしでは 100~950mV ですが、一番広いレンジを取れる ADC.ATTN_11DB を指定したときには 150~2450mV が読み取れるようです。もちろんレンジを広げるほど読み取りの粒度は粗くなります。

測定誤差や自然現象は正規分布に従うと言われているので、いくつかサンプリングした値を使って、

  • 算術平均: N サンプルの合計を N で除したもの。
  • 移動平均: キューを利用して過去 N サンプルの算術平均を取るもの。
  • トリム平均: N サンプルの最大・最小値から X サンプル除いた算術平均をとるもの。

などによって最終的な AD 値を決定します。

経験上は移動平均を利用すると突発的なノイズの影響を抑えられるので有利に働くことが多かったです。トリム平均はアルゴリズムが複雑になる上、そこまでの計算資源をかけるまでのものでもありません。

Wi-Fi に接続する

Wi-Fi ルーターがあれば簡単に接続できます。ただし規格や暗号化方式によっては接続できないことがあります。対応しているのは WEP, WPA-PSK, WPA2-PSK, WPA/WPA2-PSK の 4 種類のようです。

import network

wlan = network.WLAN(network.STA_IF)
wlan.active(True)

# アクセスポイントを探す
wlan.scan()

# アクセスポイントに接続する
wlan.connect('ssid', 'password')

# 接続状況を確認する
wlan.ifconfig()

# 切断する
wlan.disconnect()

現在時刻を取得する

ESP32 には RTC が搭載されており、MicroPython で時刻の取得・設定ができますが、正しい現在時刻を取得するにはインターネットに接続する必要があります。現在時刻を得ることによって、期待する時刻に処理を起動したり、夜間を自動判定して間欠動作の頻度を調節できたりします。

時刻の補正と言えば NTP ですが、より簡易的に API Gateway と Lambda で時刻取得用の API を作成しました。注意点としては組み込み機器向けのタイムスタンプの基準日時は 2000-01-01 00:00:00+00:00 であるということです。

import datetime
import json
import zoneinfo


def lambda_handler(event, context):
    utc = datetime.datetime.now(tz=zoneinfo.ZoneInfo('UTC'))
    utc2000 = datetime.datetime(2000, 1, 1, tzinfo=zoneinfo.ZoneInfo('UTC'))
    jst = utc.astimezone(tz=zoneinfo.ZoneInfo('Asia/Tokyo'))
    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/json'
        },
        'body': json.dumps({
            'timestamp': utc.timestamp(),
            'timestamp2000': (utc - utc2000).total_seconds(),
            'local': {
                'utc': utc.timetuple(),
                'jst': utc.timetuple()
            }
        })
    }

より正しい時刻に補正するためには

今回はそれほど精度が求められる時刻補正が必要になるわけではありませんが、NTP のように精度が必要な場合には以下の点に注意しなければなりません。

  • 時刻補正したいデバイス(A) と 時刻サーバ(B) 間の通信時間
  • B がリクエストを受けてからレスポンスするまでの処理時間
  • B がレスポンスしてから A がレスポンスを受けるまでの通信時間

このことを踏まえると、

  • A がリクエストを送信した時間 t_1
  • B がリクエストを受信した時間 t_2
  • B がレスポンスを送信した時間 t_3
  • A がレスポンスを受信した時間 t_4

の 4 つのパラメータが必要になります。ここで \frac{t_1 + t_4}{2} は A から見た真の時刻を得る平均的な絶対時間(かかる時間ではありません)で、\frac{t_2 + t_3}{2} は B から見た真の時刻を得る平均時間です。そのため \frac{(t_3 + t_2) - (t_1 + t_4)}{2} が A と B の時刻の差となり、A が保持する時刻に足すべき時間となります。A と B の時刻はずれていても問題ありません。

MicroPython ではミリ秒単位の \frac{t_1 + t_4}{2} を次のように求めることができます。この値と time.localtime(), time.mktime() を使えば RTC の時刻を補正できるはずです。

import time

a = time.ticks_ms()
b = time.ticks_ms()
# CPU時間が一周する可能性を考慮する
time.ticks_diff(b, a)

土壌湿度を計測する

空気中と水中の AD 値を測定してみる

同じタイプの土壌湿度計測モジュールを使って水分量を測る記事をいくつか確認しましたが、水を張ったカップの中にモジュールを出し入れして得られる AD 値の最大・最小値をもとに水分量を算出するといった内容のものが多いようです。後述するように実際は土壌の水分量を測らないといけないので、これは適した方法ではないのですが、少なくともモジュールが正しく動作しているかの確認にはなるので、試してみたところ以下の結果になりました。ADC は 11dB 減衰させています。

環境 AD値
空気中 2585
水中 940

もし水耕栽培をしているのであれば、水分量よりも水位のほうが重要です。簡単な方法のひとつとして長さの異なる金属棒を 2 本水の中に入れ、ショートするかどうかで確認できます。

金属メッキが露出しているタイプを水に浸けていると奇妙なことに AD 値がじわじわと上昇していきます。メッキ部分を擦ると値が急激に落ちるので酸化皮膜によって値が変動してしまうのだと思われます。水位を測るぐらいの用途にしか利用できません。

土壌の AD 値を測定してみる

実際はモジュールを土に差し込んで計測するので、空気中と水中の AD 値を測定しても意味はありません。そのため、以下の手順でなるべく本来の環境に近い状況を作り出して AD 値を計測しました。

  • 湿潤
    1. 鉢に土を入れる
    2. 鉢の底から水が染み出すまで水を注ぐ
    3. しばらく待ち、過剰な水分を排出させる
    4. 計測する
  • 乾燥
    1. 土を天日干しにする
    2. 乾燥した土を鉢に入れる
    3. 計測する

乾燥させる方法として電子レンジも検討しましたが、自然環境下ではそこまで除湿されることも考えられないので一番手軽な天日干しを選びました。

土壌湿度計測モジュールにシリコンを塗る

土壌湿度計測モジュールの表面にそのまま部品が載っているため、どう考えても耐水性・防水性があるとは考えられません。なのでシリコンを塗り付けてショートや腐食を起こさないようにします。また、モジュールの差し込み具合によって測定値が変動するのを避けるために、白線より上は表裏共にシリコンを塗っておきます。

測定する

移動平均のスクリプトを REPL で書いて試しました。

import time
import machine
p = machine.Pin(36)
a = machine.ADC(p, atten=machine.ADC.ATTN_11DB)
x = []
while True:
    if len(x) >= 10:
        x.pop(0)
    x.append(a.read())
    print(sum(x) / len(x))
    time.sleep(0.1)
環境 AD値
乾燥 2600
湿潤 960

ここでは AD 値が線形に推移すると仮定して、100 - \frac{x - 960}{2600 - 960} \cdot 100 % で水分量を算出することにしました。

注意
デバイスは一度設置するとメンテナンスがしづらくなるので、今回のように AD 値が取れる場合には AD 値をそのままクラウドに送信したほうが後々のデータの取り回しがよくなります。

おまけ: 気温と湿度を測る

DHT11 温度湿度センサーモジュールが転がっていたので、ついでに空気中の気温と湿度も測ることにしました。このモジュールは MicroPython でサポートされているので、簡単に利用できます。

import dht
import machine
d = dht.DHT11(machine.Pin(13))
d.measure()
d.measure()
d.temperature()
d.humidity()

データシートによれば、取得した値は前回計測した値なので、2 回計測することで最新の値を取得できるとのことです。

設置する

設置しました。モバイルバッテリーと ESP32 を接続し、さらにソーラーパネルからモバイルバッテリーに給電…あれ?ここではじめて気づきました。使用しているモバイルバッテリーが給電しつつ充電できないことに…。

注意
パススルー充電(ELECOM製では「まとめて充電」という表記)ができるバッテリーでないと、給電が優先されて満充電にならないと電源供給がされません。

というわけで新しいモバイルバッテリーを購入しました。ソーラーパネルからの給電があれば充電しつつ電力供給ができますし、夜間はモバイルバッテリーが供給でき…あまりにも消費電力が低すぎて給電を打ち切られている…。そんなバカな…(膝から崩れ落ちる)

注意
モバイルバッテリーは賢いので、消費電力が低いと給電を打ち切ります。

それなら全部太陽光から電力を賄ってやる、とソーラーパネルと直結しました。ESP32 の LED が不安定に揺れています。ダメだ…全然足りてねえ…。なにが 5V/2A 給電じゃいい加減にしろ!

なので、100均で電池を使うタイプのモバイルバッテリーを購入しようと思いましたが、あちこちまわっても手に入らなかったのでスイッチサイエンスを見てみると…代わりになりそうなものがありました。これは ESP32 の給電が止まらず、パススルー充電もできる優れものです。パススルーしてしまうのでソーラーパネルと繋ぐとまともに ESP 32 は動かなくなりますが、繋がなければいいので、とりあえずこれでやりたいことは実現できました。やはり餅は餅屋…。

Discord と連携する

Discord の Webhook 連携と Lambda + EventBridge で 1 時間おきに温度・湿度・土壌水分量を投稿するようにしました。これについては User-Agent を適当なものに偽装しないといけないことをのぞいて特に問題ないので割愛します。

3日でバッテリーを消耗しきった (2022/7/10 追記)

ディープスリープを使い 30 分間隔で起動していたのにも関わらず 3 日で止まってしまったので、まずは基板上の電源インジケータ LED を潰して様子を見てみることにしました。

まとめ

  • ESP32 と周辺モジュールがあればセンシングは簡単にできます。
  • 単純なソフトウェア開発も楽しいですが、今回のように物理的な対象があると楽しいです。
  • モバイルバッテリーは賢いです。誰かこの超放熱型モバイルバッテリーを買い取ってください。

Discussion