Python
SQLite3
RaspberryPi
IoT
Bokeh

Raspberry Piで計測したデータをグラフ化 全部のせPython(SQLite + Bokeh + Flask)で作るグラフアプリ 


はじめに

Raspberry Piと水分センサで植物の水分を計測し、計測したデータをデータベースに登録します。そして、植物の水分量の変化をグラフ化し、ブラウザからいつでも植物の状態が確認できます。フロントエンドの技術は一切不要です。

全部Pythonで、やります。

全部Pythonで、できます。

全部Pythonで、できてます。

Python最強!!

本プログラムはブラウザから、Raspberry PiのIPアドレス:5000(※)にアクセスすると、植物の水分量の変化を視覚的に見ることができます。:droplet:

スクリーンショット 2018-04-27 01.26.43.png

(※)Flaskのデフォルトポート番号(変更可能)

本記事はSQLiteの使い方やプログラムして悩んだ点などをナレッジとしてまとめています。


全体概要

グラフ表示は以下の3つの処理を行い、グラフを描画したファイル(lines.html)を生成して、そのファイルをFlaskから読み込ませることで実現しています。


  • 計測したデータをデータベース(SQLite)に登録


  • 12時間分のデータをリストに格納し、かつ、日付で降順にソートさせて、オブジェクトに格納

  • オブジェクトに格納されたデータを逆から取り出して、Bokehでグラフを描画したファイル(linet.html)を生成

bokeh.png


SQLite

SQLiteはOSSのデータベース(RDBMS)です。

特徴としては、機能は少ないですが軽量で組み込み型データベースエンジンとしての側面を持っています。そのため、ブラウザのFirefoxやモバイルOSであるAndroidなどで、組み込みのデータベースとして利用されています。データを単一ファイルに保存するので、Raspberry Piでデータベースを動かすのにはちょうどいいと思います。

スクリーンショット 2018-04-30 16.51.17.png


SQLiteのインストール

Python3の場合、標準ライブラリでインストールすることなく使用できますが、コマンドラインツールが使用できないため、以下のコマンドでインストールします。

$ sudo apt-get install sqlite3


データベースの作成

以下のコマンドでデータベースが作成されます。コマンド実行後、ファイル名.dbが生成されるので、sqlite3コマンドの引数に生成された.dbファイルを指定することでデータベースを利用できます。

$ sqlite3 moisture.db

作成したデータベースファイルの確認

sqlite> .database

main: /home/pi/python/moisture.db

センサーで計測したデータを格納するテーブルを作成

sqlite> create table moisture(date text, volts int, primary key(date, volts));

作成したテーブルの確認

sqlite> .tables

moisture


コマンドラインツール

デフォルトの場合、SQLの出力が見づらいためカラムモードにすることで見やすくなります。


  • 実行例(デフォルト)

sqlite> select * from moisture order by date desc limit 5;

Sat Apr 21 12:17:15 2018|11
Sat Apr 21 12:17:12 2018|7
Sat Apr 21 12:17:09 2018|5
Sat Apr 21 11:58:07 2018|7
Sat Apr 21 11:58:03 2018|12


  • 表示変更

.header on

.mode column


  • 実行例(カラムモード)

sqlite> .header on

sqlite> .mode colum
sqlite> select * from moisture order by date desc limit 5;
date volts
------------------------ ----------
Sat Apr 21 12:17:15 2018 11
Sat Apr 21 12:17:12 2018 7
Sat Apr 21 12:17:09 2018 5
Sat Apr 21 11:58:07 2018 7
Sat Apr 21 11:58:03 2018 12


Bokeh

Bokehはグラフ表示できるPythonのライブラリです。

折れ線グラフ、棒グラフ、クールなグラフまで多彩な描画ができます。

Bokehの凄いところは、開発者がフロントエンドの技術を意識することなく、オブジェクトに対して描きたいグラフをプロットするだけで、グラフを描画したhtmlファイルを生成します。

スクリーンショット 2018-04-30 16.47.34.png

bokehのインストール

$ sudo pip3 install bokeh


Flask

FlaskもPythonのライブラリで軽量フレームワークを提供します。

自身を「マイクロフレームワーク」と呼んでいます。

一通りのHTTPメソッドを使用することができるので、軽量なWebアプリケーションの作成が行えます。

スクリーンショット 2018-04-30 16.49.51.png

Flaskのインストール

$ sudo pip3 install Flask


プログラム

moisture.pyとFlask.pyは同じディレクトリに格納します。

また、Flaskから読み込むlines.htmlファイルを格納するためのtemplatesディレクトリを、プログラムを起点とするディレクトリ配下に作成します。


moisture.py

#! /usr/bin/env python3

# _*_ coding: utf-8 _*_

# ADS1015の関数を読み込む
import time, signal, sys
import Adafruit_ADS1x15

# Messaging APIのパスを通す
sys.path.append('/home/pi/.local/lib/python3.5/site-packages/')

# Messaging APIのモジュールをインポート
from linebot import LineBotApi
from linebot.models import TextSendMessage
from linebot.exceptions import LineBotApiError

# sqlite3、bokehをインポート
import sqlite3
import datetime
from bokeh.plotting import figure, output_file, show

# channel access tokenを指定
line_bot_api = LineBotApi('<channel access token>')

# user IDとプッシュメッセージを指定
def message1():
try:
line_bot_api.push_message('<to>', TextSendMessage(text='お水ください'))
except LineBotApiError as e:
# error handle
print("Error occurred")

# logに書き込み
def log_write():
output_time = time.asctime()
log_file = open("/var/log/python/moisture.log","a+", encoding="UTF-8")
log_file.write(output_time + " : " + str(volts) + "V" + "\n")
log_file.close()

# データベースに書き込み
def sqlite_insert():
dbname = '/home/pi/python/moisture.db'
con = sqlite3.connect(dbname)
cur = con.cursor()
output_time = datetime.datetime.now()
output_time = "{0:%Y-%m-%dT%H:%M:%SZ}".format(output_time)
data = (output_time, (volts))
cur.execute('insert into moisture (date, volts) values (?,?)', (data))
con.commit()
con.close()

# データベースから取り出し
def sqlite_select():
dbname = '/home/pi/python/moisture.db'
con = sqlite3.connect(dbname)
cur = con.cursor()
cur.execute('SELECT date FROM moisture order by date desc limit 72')
global x
x = [(x[0]) for x in cur.fetchall()]
cur.execute('SELECT volts FROM moisture order by date desc limit 72')
global y
y = [(y[0]) for y in cur.fetchall()]
con.close()

# グラフ描画
def graph_draw():
# prepare some data
x.reverse()
y.reverse()
# output to static HTML file
output_file("/home/pi/python/templates/lines.html")
# create a new plot with a title and axis labels
p = figure(title="moisture data", plot_width=1200, plot_height=500, x_axis_label='x', y_axis_label='y', x_range=x)
p.vbar(x=x, top=y, width=0.3)
p.y_range.start = 0
p.xaxis.major_label_orientation = 1
# add a line renderer with legend and line thickness
p.line(x, y, line_width=5,legend="moisture:value", color="limegreen")
# show the results
show(p)

# 計測の範囲を指定(1を指定した場合は-4.096Vから4.96Vまで計測可能)
GAIN = 1

abc = Adafruit_ADS1x15.ADS1015()

count = 0

while True:
volts = abc.read_adc(0, gain=GAIN)
if volts >= 100:
print( "State with moistured : " + str(volts) + "V" )
log_write()
sqlite_insert()
sqlite_select()
graph_draw()
elif volts < 100 and volts >= 1:
print( "Condition with reduced moisture : " + str(volts) + "V" )
log_write()
sqlite_insert()
sqlite_select()
graph_draw()
else:
print( "No moisture condition : " + str(volts) + "V")
log_write()
sqlite_insert()
sqlite_select()
graph_draw()
print(count)
if count == 3:
message1()
break
else:
count += 1
time.sleep(600)


Flask.py

#! /usr/bin/env python3

# _*_ coding: utf-8 _*_

from flask import Flask, render_template

app = Flask(__name__)
@app.route('/')
def index():
return render_template('lines.html')

app.run(host='0.0.0.0', debug=True)


ナレッジ

プログラムしていて、分かったこと、悩んだところ、考えさせらた点についてまとめました。


  • SQLiteのデータベースファイルのパス

    データベースファイルは"/"からのパスを起点としています。プログラムが動作しないときはデータベースファイルをフルパスで指定してください。


  • SQLiteのデータを日付でソートする

    当プログラムは12時間分の計測したデータを、selectしオブジェクトに格納して取り出しています。リアルタイムに日付データでソート(降順)させるために、sqliteの場合以下の形式にする必要(※)があります。


   YYYY-MM-DDTHH:MM:SS

(※)SQLiteの場合日付のデータ型がないため



  • SQLiteのデータをselectしてリストで返してタプルを消す

    SQLiteでSELEC 文を実行した後データを取得する方法は3つあります。以下、公式より


SELECT 文を実行した後データを取得する方法は3つありどれを使っても構いません。一つはカーソルをイテレータ (iterator) として扱う、一つはカーソルの fetchone() メソッドを呼んで一致した内の一行を取得する、もう一つは fetchall() メソッドを呼んで一致した全ての行のリストとして受け取る、という3つです。


当プログラムはリストで返したかったので、fetchall() メソッドを使用しています。

少し悩まされたのが、そのままfetchall() メソッドを使用すると、リストで返ってくるのですが、中身がタプルになって返ってくるところでした。(※)

(※)Bokehライブラリでグラフ描画するにあたり、変数yはリストから取り出したかったため

fetchall() メソッドの実行例

そのまま、取り出すとタプルで返ってきます。

y = cur.fetchall()の場合

[(8,), (8,), (9,), (4,), (5,)]

リスト内包表記で新しいシーケンスにすることで解決できました。

y = [(y[0]) for y in cur.fetchall()]に修正

[8, 8, 9, 4, 5]



  • SQLiteのデータをselectしてリストで返して、逆から取り出す

    リストのデータを逆から取り出す方法はいくつかあります。当プログラムでは、reverse()メソッドを使用することで逆から取り出しています。

x.reverse()

y.reverse()


  • グローバル変数

    なるべくグローバル変数は使用したくありませんでしたが、計測したデータの受け渡しをするためにグローバル変数を使用しました。関数型プログラミングやオブジェクト思考プログラミングはまだまだ、勉強中であるため、グローバル変数を使用しないようにプログラムしたいです。


  • bokehで日付表示

    ここが一番悩まされました。やろうとしていること(x軸に日付、y軸にデータを描画)は単純なのですが、x軸に日付を描画させるところが中々上手くいきませんでした。


結果的に、公式のユーザーガイドを参考にしてx_range=xとすることで、日付データを描画できました。

p = figure(title="moisture data", plot_width=1200, plot_height=500, x_axis_label='x', y_axis_label='y', x_range=x)



  • Flaskのリロード

    Flaskは起動時にメモリに読み込むため、コンテンツに変更(※)があった場合は再読み込みさせる必要があります。当プログラムは個人で開発しているだけなので、デバッグモードにすることで再読み込みを回避しています。そのため、本運用を考える場合はWebサーバを使用した上で、uWSGIと組み合わせえる方法などで再読み込みができます。

app.run(host='0.0.0.0', debug=True)

(※)当プログラムの場合、10分おきに計測してlines.htmlを更新しているので、ファイル更新後にブラウザからアクセスしても最新のデータが表示されないため


さいごに

最初は、Raspberry Piで植物の水分を検知し、しきい値を越えたらLINE APIで通知するプログラムを作りました。

Pythonで作る簡単植物通知システム Raspberry Pi + 水分センサ + Messaging API

次に、検出したデータをロギングするために、センサーで検出したデータをテキストログに出力させました。

Raspberry Piのセンサーで検出したデータをPythonでテキストログに出力させる

今回は、センサーで検出したデータをデータベースに登録してグラフ表示させたいと思いました。nginx+phpは面白くないので、なるべく手間かけることなく、node.jsで実現しようとしましたが、bokenを知ったので、全てPythonで実装することにしました。

今度はGoogle AIYのVoice Kitと連携させて喋らせようかなと考えてます。


参考


追記 2018.10.23 

本記事執筆後、バグが発覚し、プログラムを修正したので追記になります。


  • バグ内容

    初回計測時に約16kbのlines.htmlファイルが生成されます。

    本プログラムは10分おきに水分を計測し、また、グラフ描画処理も合わせて行っていますが、計測する度にlines.htmlファイルが肥大化していました。そのため、ファイルが1MB等を超えて大きくなるとブラウザで表示できない事象が発生。


  • 原因

    原因は、全ての機能を一つのプログラムに集約していたことにより、wheel処理から呼び出されたbokehのグラフ描画時に、計測したデータのリスト(変数)が初期化されず、蓄積され続けることでファイルが肥大化していたと仮定。


  • 修正内容

    変数の削除処理を入れて試しましたが、効果がなかったので、プログラムをmoisture_main.py(データの計測、テキストログ及びデータベース(SQLite)の登録)とmoisture_create.py(データの取り出し、グラフ描画)に分けることで、グラフ描画処理をwheel処理から分離させました。また、全体的にオブジェクト指向に修正しました。プログラム修正後、lines.htmlファイルの肥大化は解消され、起動後しばらくしても正常にアクセスできることを確認。


  • 設定

    moisture_main.pyはRaspberry Pi起動時に実行し、moisture_create.pyはcronでセンサーの検知時間に合わせて10分毎に設定。



プログラム(修正バージョン)


  • moisture_main.py

#! /usr/bin/env python3

# _*_ coding: utf-8 _*_

# ADS1015の関数を読み込む
import time, signal, sys
import Adafruit_ADS1x15

# Messaging APIのパスを通す
sys.path.append('/home/pi/.local/lib/python3.5/site-packages/')

# Messaging APIのモジュールをインポート
from linebot import LineBotApi
from linebot.models import TextSendMessage
from linebot.exceptions import LineBotApiError

# sqlite3をインポート
import sqlite3
import datetime

# channel access tokenを指定
line_bot_api = LineBotApi('<channel access token>')

# user IDとプッシュメッセージを指定
def message1():
try:
line_bot_api.push_message('<to>', TextSendMessage(text='お水ください'))
except LineBotApiError as e:
# error handle
print("Error occurred")

# クラスを作成
class Moisture:
# 計測の範囲を指定(1を指定した場合は-4.096Vから4.96Vまで計測可能)
def __init__(self):
GAIN = 1
abc = Adafruit_ADS1x15.ADS1015()
volts = abc.read_adc(0, gain=GAIN)
self.volts = volts

# logに書き込み
def log_write(self):
output_time = time.asctime()
log_file = open("/var/log/python/moisture.log","a+", encoding="UTF-8")
log_file.write(output_time + " : " + str(self.volts) + "V" + "\n")
log_file.close()

# データベースに書き込み
def sqlite_insert(self):
dbname = '/home/pi/python/moisture.db'
con = sqlite3.connect(dbname)
cur = con.cursor()
output_time = datetime.datetime.now()
output_time = "{0:%Y-%m-%dT%H:%M:%SZ}".format(output_time)
data = (output_time, self.volts)
cur.execute('insert into moisture (date, volts) values (?,?)', (data))
con.commit()
con.close()

# しきい値を設定
count = 0

while True:
# インスタンス生成
moisture_ins = Moisture()
volts = moisture_ins.volts

if volts >= 100:
print( "State with moistured : " + str(volts) + "V" )
moisture_ins.log_write()
moisture_ins.sqlite_insert()
elif volts < 100 and volts >= 1:
print( "Condition with reduced moisture : " + str(volts) + "V" )
moisture_ins.log_write()
moisture_ins.sqlite_insert()
else:
print( "No moisture condition : " + str(volts) + "V")
moisture_ins.log_write()
moisture_ins.sqlite_insert()
if count == 3:
message1()
break
else:
count += 1
time.sleep(600)


  • moisture_create.py

#! /usr/bin/env python3

# _*_ coding: utf-8 _*_

# パスを通す
import sys
sys.path.append('/home/pi/.local/lib/python3.5/site-packages/')

# sqlite3、bokehをインポート
import sqlite3
import datetime
from bokeh.plotting import figure, output_file, show

# クラスを作成
class Moisture:
x = []
y = []

def __init__(self):
pass

# データベースから取り出し
def sqlite_select(self):
dbname = '/home/pi/python/moisture.db'
con = sqlite3.connect(dbname)
cur = con.cursor()
cur.execute('SELECT date FROM moisture order by date desc limit 72')
self.x = ([(x[0]) for x in cur.fetchall()])
cur.execute('SELECT volts FROM moisture order by date desc limit 72')
self.y = ([(y[0]) for y in cur.fetchall()])
con.close()

# グラフ描画
def graph_draw(self):
# prepare some data
x = self.x[::-1]
y = self.y[::-1]
# output to static HTML file
output_file("/home/pi/python/templates/lines.html")
# create a new plot with a title and axis labels
p = figure(title="moisture data", plot_width=1200, plot_height=500, x_axis_label='x', y_axis_label='y', x_range=x)
p.vbar(x=x, top=y, width=0.3)
p.y_range.start = 0
p.xaxis.major_label_orientation = 1
# add a line renderer with legend and line thickness
p.line(x, y, line_width=5,legend="moisture:value", color="limegreen")
# show the results
show(p)

# インスタンス生成
moisture_ins = Moisture()
moisture_ins.sqlite_select()
moisture_ins.graph_draw()


  • /etc/rc.local

echo `date` "moisture.py start" >> /var/log/python/moisture.log

/usr/bin/python3 /home/pi/python/moisture_main.py &
/usr/bin/python3 /home/pi/python/Flask.py &


  • cron設定

*/10 * * * * /usr/bin/python3 /home/pi/python/moisture_create.py

0 7 * * * /home/pi/python/restart_flask.sh


  • restart_flask.sh

#!/bin/bash

f_pid=`ps aux | grep Flask | grep -v grep | awk '{print $2}'`
for i in $f_pid ; do sudo kill $i ; done > /dev/null 2>&1

sleep 3

ps_c=`ps aux | grep Flask | grep -v grep | wc -l`
if [ $ps_c -eq 0 ]; then
/usr/bin/python3 /home/pi/python/Flask.py &
fi

上記、restart_flask.shシェルをcronで1日1回実行し、Flask.pyプログラムの再起動を行なっています。

バグとはまた別の事象で、時々Flaskにアクセスできなくなることがありましたが、当シェルを仕込んだところ、事象は改善されました。恐らく、ルーター再起動時のWifi再接続が原因と思われる。


教訓


関数は1つのことをすべきである。そのことを徹底すべきだ。たったそれだけのことに特化すべきだ。-Robert C. Martin