15
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ラズパイでCO2濃度を測定してPlotly Dashで可視化

Last updated at Posted at 2022-11-30

スクリーンショット 2022-11-29 15.06.39.png

はじめに

AmazonでCO2モニターを色々調べてみたものの、ロガー機能やデータの可視化を求めると思ったより高くなりそうだったのと、測定頻度の調整や可視化部分のカスタマイズを自由にやりたかったため、今更ながら試しに自作してみた際の備忘録です。

CO2センサーをラズパイに繋げてリアルタイムで測定し、測定データをラズパイ上に保存できるようにしました。またPythonの可視化ライブラリの一つであるPlotly Dashを使ったダッシュボードをラズパイ上に立ち上げ、ローカルネットワーク内であればブラウザから確認できるようにしました。

やりたいこと

  • 部屋のCO2濃度を定時測定し、測定データを保存する
  • 測定データを可視化したダッシュボードページをラズパイ上に立ち上げ、ブラウザで確認できるようにする

AWSと連携してアラート設定したり、Googleスプレッドシートにデータを吸い上げることも考えたのですが、今回は試しにラズパイ上だけで構成を完結させました。

買ったもの

  • Raspberry Pi Zero WH
    • そこそこ安く、デフォルトでGPIOピンヘッダーがはんだ付けされており、かつWiFi接続できるモデルだったので購入。
  • MH-Z19C
    • CO2センサー「MH-Z19」シリーズの一つ。比較的情報が多かったのと在庫が残っていたため購入。
  • GeeekPi Raspberry Pi Zero 2 Wケースキット
    • 筐体ケースや電源ケーブル、ヒートシンクなどがまとまったキット。
  • ジャンパーケーブル
    • センサーとラズパイ本体を接続するためのケーブル。
  • microSDカード
    • ラズパイOSのインストール先となるmicroSDカード。

全部あわせて11000円くらい(送料込み)でした。

やったこと

工作

特別な工具も必要なく、10分くらいで完了できました。

  • ラズパイ本体にケースキットを取り付け
    • ケースをネジとナットで固定
    • ヒートシンク(粘着テープ付き)をCPUに取り付け
  • ラズパイ本体とCO2センサーを接続(参考
    • ジャンパーケーブル4本使って接続

こんな感じになります(左がラズパイ、右がセンサー)。
IMG_0560.jpg

ラズパイのセットアップ

既にたくさんの方が記事を書かれてますので、参考にしつつセットアップしていきます。

  • microSDにラズパイOSをインストール
    • こちらを参考に、 公式のイメージ書き込みツールを使ってインストールします。今回はラズパイ上での作業は基本的にMacからSSH接続して行うため、GUI無しのバージョン(Raspberry Pi OS Lite (32-bit))を選択しました。5分程度でインストール完了。
  • microSDをラズパイに差し込み、電源を入れて起動
    • 初回起動時は5分程度で起動完了。
  • SSHで接続
    • OSインストール時、ユーザー名とホスト名がデフォルトのままであれば
      ssh pi@raspberrypi.localで接続できます。
  • シリアル通信の設定
    • センサー値をシリアル通信で取得するための設定が必要とのことなので、 こちらを参考にsudo raspi-configコマンドから設定します。

プログラミング

ここからプログラミングしていきます。まずPythonでセンサー値を取得し、一定時間おきに測定値をcsvファイルに書き出すようにします。また、測定値をPlotly Dashで可視化できるようにします。

以下、作業ディレクトリは/home/pi/raspberrypi_sensorとします。

準備

Python環境はpython 3.9.2がデフォルトでインストールされていたので、そのまま使います。
pipは入っていなかったのでインストールします。

sudo apt update
sudo apt -y install python3-pip

jqコマンドをあとで使うのでインストールしておきます。

sudo apt -y install jq

センサーから測定値を毎分取得し、csvファイルに出力

CO2センサーから測定値を取得するため、mh_z19というpythonライブラリを利用させていただきました。

sudo pip install mh_z19

インストール後sudo python -m mh_z19を実行すると{"co2": 400}のような形式で測定値が返ってくるので、jqコマンドを使って値のみを抜き出すスクリプトを作ります。

sampling.sh
#!/bin/sh

result=$(sudo python -m mh_z19 | jq .co2)

if [ -z "$result" ]; then
  echo "None"
else
  echo "$result"
fi

さらに、測定値をcsvファイルに書き込むスクリプトも用意します。csvファイルはdataディレクトリ以下に作成されます。

write_csv.sh
#!/bin/sh

mkdir -p data

current_date=$(date +"%Y-%m-%d")
if [ ! -f data/"$current_date".csv ] ; then
  touch data/"$current_date".csv
  echo "timestamp co2_ppm" >> data/"$current_date".csv
fi

timestamp=$(date +%s)
co2_ppm=$(bash sampling.sh)
echo "$timestamp $co2_ppm" >> data/"$current_date".csv

毎分測定したいので、write_csv.shをcronで定期実行します。crontab -eコマンドを実行し、以下を追記します。

*/1 * * * * cd /home/pi/raspberrypi_sensor; bash write_csv.sh;

ここまででCO2濃度を毎分測定してcsvファイルに蓄積する仕組みができました。csvファイルには以下のような形式で測定時刻と測定値を記録しています。

sample.csv
timestamp co2_ppm
1669307341 581
1669307401 571
1669307461 568
...

Plotly Dashを使って測定値を可視化

測定データが溜まるようになったので、ダッシュボードを作って可視化します。Pythonで利用できる可視化ライブラリはいくつかあるのですが、今回は個人的に触ったことのあったPlotly Dashを使って簡易ダッシュボードを作りました。

まずPlotly Dashをpipでインストールします(そこそこ時間かかります)。

sudo pip install dash

ダッシュボードを作ります。以下は主要部分のコードだけですが、詳細はこちらに公開していますので、よければご参照ください。

app.py
# -*- coding: utf-8 -*-

import csv
import glob
import subprocess
from datetime import datetime

import dash
import plotly.graph_objects as go
from dash import dcc, html
from dash.dependencies import Input, Output


def get_date():
    return str(datetime.now()).split(' ')[0]


def get_path(d):
    return f'data/{d}.csv'


def get_csv_dates():
    files = glob.glob('data/*')
    files.sort(reverse=True)
    return [f.split('/')[1].split('.')[0] for f in files]


def read_csv(path):
    with open(path) as f:
        reader = csv.reader(f, delimiter=' ')
        data = [row for row in reader]
        data_t = [list(x) for x in zip(*data)]
    return data_t


def sampling():
    sample = int(subprocess.check_output(['bash', 'sampling.sh']))
    datetime_now = datetime.now().strftime("%Y/%m/%d - %H:%M:%S")
    return f'CO2: {sample} ppm', f'{datetime_now}'


def shutdown():
    subprocess.Popen(['bash', 'shutdown.sh'])


def get_co2_fig(path):
    data = read_csv(path)
    layout = go.Layout(plot_bgcolor='WhiteSmoke', paper_bgcolor='WhiteSmoke')
    fig = go.Figure(layout=layout)
    timestamp = [datetime.fromtimestamp(int(i)) for i in data[0][1:]]
    co2_ppm = [int(i) for i in data[1][1:]]
    fig.add_trace(go.Scatter(x=timestamp, y=co2_ppm, line=dict(width=2, color='Crimson')))
    fig.update_xaxes(showgrid=False)
    fig.update_yaxes(showgrid=False)
    fig.update_layout(yaxis_title='CO2 (ppm)',
                      margin=dict(l=200, r=200, t=10, b=10), showlegend=False,
                      uirevision='true', height=300)
    return fig


graph_types = ['CO2']
csv_dates = get_csv_dates()
current_date = get_date()
co2_fig = get_co2_fig(get_path(current_date))

app = dash.Dash(__name__)
app.title = 'Raspberry Pi Sensor Monitor'
app.layout = html.Div(children=[
    html.Br(),
    html.H3(children='Raspberry Pi Sensor Monitor', style={'fontFamily': 'Arial Black', 'fontSize': 48}),
    html.H3(id='container-sample-main', children=' ', style={'fontFamily': 'Arial Black', 'fontSize': 32}),
    html.H6(id='container-sample-sub', children=' '),
    html.Hr(),
    html.Div(children=[
        html.Div(children=[
                html.Label('Type'),
                dcc.Dropdown(graph_types, value=graph_types[0], id='dropdown_graphtype', style={'textAlign': 'left'})
            ], style={'width': '20%', 'display': 'inline-block', 'marginRight': 5}),
        html.Div(children=[
                html.Label('Date'),
                dcc.Dropdown(csv_dates, value=current_date, id='dropdown_date', style={'textAlign': 'left'})
            ], style={'width': '30%', 'display': 'inline-block', 'marginLeft': 5})
    ], style={'width': '50%', 'display': 'inline-block'}),
    dcc.Graph(id='co2-graph',
              figure=co2_fig,
              config={'displayModeBar': False, 'responsive': False}),
    html.Hr(),
    html.Div(children=[
            html.Button('Shutdown', id='shutdown', n_clicks=0),
            html.H6(id='shutdown_message', children='', style={'fontSize': 16}),
            html.Br()]),
    html.Img(src='assets/plotly_logo.webp', alt='image', style={'width': '12%', 'marginBottom': 40}),
    dcc.Interval(id='interval', interval=10000, n_intervals=0)
], style={'textAlign': 'center', 'backgroundColor': 'WhiteSmoke', 'color': '#2F3F5C'})


@app.callback([Output('container-sample-main', 'children'),
               Output('container-sample-sub', 'children'),
               Output('co2-graph', 'figure'),
               Output('dropdown_date', 'options'),
               Output('dropdown_date', 'value')],
              [Input('interval', 'n_intervals'),
               Input('dropdown_date', 'value')])
def trigger_by_interval(n, selected_date):
    global current_date, csv_dates, co2_fig
    date = get_date()
    csv_dates = get_csv_dates()

    co2_ppm, updated_time = sampling()
    if current_date != date:
        current_date = date
        print(f'new csv created: {get_path(current_date)}')
        co2_fig = get_co2_fig(get_path(current_date))
        return co2_ppm, updated_time, co2_fig, csv_dates, current_date

    if selected_date is None or selected_date == current_date:
        print(f'selected_date: {selected_date} -> use {current_date}.csv')
        co2_fig = get_co2_fig(get_path(current_date))
        return co2_ppm, updated_time, co2_fig, csv_dates, current_date
    else:
        print(f'selected_date: {selected_date}.csv exists -> use it')
        co2_fig = get_co2_fig(get_path(selected_date))
        return co2_ppm, updated_time, co2_fig, csv_dates, selected_date


@app.callback(Output('shutdown_message', 'children'), Input('shutdown', 'n_clicks'))
def shutdown_button_clicked(n_clicks):
    if n_clicks > 0:
        shutdown()
        return 'shutdown signal send...'
    else:
        return ''


if __name__ == '__main__':
    app.run_server(debug=False, host="0.0.0.0")

以下を実行するとローカルホストの8050番にダッシュボードが立ち上がります。

python app.py

できたもの

ローカルネットワーク内からraspberrypi.local:8050にアクセスすると以下のようなダッシュボードが表示されます。2行目のCO2測定値は10秒に1度センサーから取得し、更新しています。グラフはPlotlyを使って表示しており、インタラクティブに操作できます。またドロップダウンから日付を指定して過去のグラフを見られるようにしています(が、反映は激遅…)。

sensor_monitor_01_20sec.gif

その他

シャットダウンボタンをつける

ラズパイをシャットダウンするために毎回SSHしてコマンドを叩くのは面倒なので、ダッシュボードページの下部にシャットダウンボタンをつけました。以下のスクリプトをapp.pyから叩いています。

shutdown.sh
#!/bin/sh

echo "shutdown requested"
sleep 3
sudo shutdown -h now

ラズパイ起動時、自動でダッシュボードを立ち上げる

同様に、ラズパイを再起動したときに毎回SSHしてダッシュボードを立ち上げるのは面倒なので、Systemdを使って自動で立ち上がるように設定しました。以下の設定ファイルを作成します。

sensor_monitor.service
[Unit]
Description=Run sensor monitor
After=network.target

[Service]
ExecStart=/usr/bin/python /home/pi/raspberrypi_sensor/app.py
WorkingDirectory=/home/pi/raspberrypi_sensor
Restart=always

[Install]
WantedBy=multi-user.target
  • ExecStartに実行コマンドをフルパスで記述
  • WorkingDirectoryに実行ディレクトリを指定
  • Restart=alwaysを指定することで、プロセスが落ちたら自動復旧

これを/etc/systemd/system/以下に配置し、以下を実行することで次回起動以降は自動でダッシュボードが立ち上がるようになります。

sudo systemctl enable sensor_monitor.service

自動起動できるか確認してみます。ラズパイを再起動した後SSHで再接続し、以下を実行することでダッシュボードのプロセスが起動しているかを見ます。

ps -aux | grep "/usr/bin/python /home/pi/raspberrypi_sensor/app.py"

以下が出力されました。この例ではpid 25414で自動実行できていることがわかります。

pi       21909  0.0  0.4   7436  2024 pts/0    S+   16:57   0:00 grep --color=auto app.py
root     25414  3.2  6.7  73304 29796 ?        Ss   02:04  28:38 /usr/bin/python /home/pi/raspberrypi_sensor/app.py

おわりに

ラズパイでのCO2測定は既にかなり多くの方々が試されているのですが、ラズパイ上にダッシュボードを乗せる例はあまりなかったので、(良し悪しはさておき)こういうのもあるんだな〜程度に読んでもらえたら嬉しいです。そのうちCO2以外も測定できるようにしたい。

参考

15
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?