二酸化炭素濃度をラズパイで測り、換気が必要な時にスマートスピーカーが声で教えてくれる仕組みを作ろう

二酸化炭素濃度をラズパイで測り、換気が必要な時にスマートスピーカーが声で教えてくれる仕組みを作ろう
Page content

本記事は次の記事の続編です。

今回は、NDIR式の二酸化炭素センサーMH-Z19Cの入手と、次の作業に関する記録です。

  1. CO2センサーを、すでに温湿度・気圧センサーを接続しているRaspberry Pi 4に追加接続
  2. リビングの二酸化炭素濃度が上昇したときに住人へ換気を促す仕組みを構築

1. はじめに

購入した材料

Raspberry Piに接続できるCO2センサーとしては、NDIR (非分散型赤外線吸収法) 式の「MH-Z19」という型番のものがメジャーであるらしい。そのシリーズである、末尾に「C」が付いたMH-Z19Cと、ジャンパーワイヤーを購入した。

分類品名購入価格(円)備考
CO2センサーWinsen MH-Z19C3,100メルカリ
ジャンパーワイヤーELEGOO JP-EL-CP-004990Amazon.co.jp

ELEGOO 120pcs多色デュポンワイヤー、arduino用ワイヤ—ゲ—ジ28AWG オス-メス オス-オス メス –メス ブレッドボードジャンパーワイヤー

ELEGOO 120pcs多色デュポンワイヤー、arduino用ワイヤ—ゲ—ジ28AWG オス-メス オス-オス メス –メス ブレッドボードジャンパーワイヤー

ELEGOO

ラズパイOSでCO2センサーの動作確認

最初に、届いたCO2センサーのハードウェア的な動作確認をせねばならない。最終的にはUbuntuサーバをやっているラズパイと接続したいわけだが稼働中であり、まずは、普段暇しているRaspberry Pi 400と接続する。Raspberry Pi 400のOSは標準的な「Raspberry Pi OS」にしてあるおかげで、CO2センサーとの接続に関してウェブ上に多くある先人の知見を参照でき、戸惑う部分がない。

次の2つのページを参照して、Rapsberry Pi 400にて、センサーが示すCO2濃度 (単位はppm) の値を取得できた。

部屋の窓を開けてたっぷり外気を取り入れた状態を計測すると、それらしく約400ppmを示す。また、センサーに息を吹きかけるとちゃんと濃度が上がる、という理科の実験をして、うちの子どもの気を引くことにも成功した。

$ sudo python3 -m mh_z19
{"co2": 948}

2. Ubuntu (Raspberry Pi 4) にCO2センサーを接続

温湿度・気圧センサーと同時接続

自宅サーバのRaspberry Pi 4には、2020-11-19の記事にて紹介した状態で、すでに温湿度・気圧センサーのBME280を接続している。今回は、このBME280用の配線が6本あるところに、CO2センサーMH-Z19C用の配線を4本加えることになる。

なお、各センサーとの接続に用いるラズパイ側のGPIOピンは、電源系を別にすると、前者は「SDA」と「SCL」、後者は「TXD」と「RXD」であり、互いに独立している。つまりラズパイ1台に対し、物理的にはこれらセンサー2つを無加工で同時接続可能ということだ。各センサーの「GND (Ground)」として指定のピンがバッティングするところは、どのGNDピンでも良いだろうと判断して配線した。

ラズパイUbuntuでMH-Z19を使用する設定

自宅サーバRaspberry Pi 4のOSであるUbuntu (20.04.1 LTS (Focal)) で、CO2センサーMH-Z19Cを使用する設定は、Raspberry Pi OSの場合とは勝手が異なるらしい。シリアル通信周りが異なるため、と推測している。

実際にやった手順

今回はいろいろと情報検索したり模索した結果、次の参考ページの「Answer」欄にある情報を元に、

次のような手順で、Ubuntu (Raspberry Pi 4) にてCO2センサーMH-Z19Cを使用できるようにした。一部パスしたところもある。

  1. 【パスした】add enable_uart=1 to /boot/config.txt
    • パスした理由: Ubuntuでは/boot/firmware/config.txtにすでにenable_uart=1が書いてあるため
  2. remove console=serial0,115200 from /boot/firmware/cmdline.txt on Ubuntu and /boot/cmdline.txt on Raspberry Pi OS
  3. disable the serial console: sudo systemctl stop serial-getty@ttyS0.service && sudo systemctl disable serial-getty@ttyS0.service
  4. 【パスした】make sure you have pyserial installed if you’re using the python serial library, not python-serial from apt.
    • パスした理由: システムでの余計な変更を避けるため最初パスしたら、そのまま動いたので。私の環境にはpyserialが入っていたのだろう
  5. create the following udev file: 【→作成したファイル】/etc/udev/rules.d/98-mhz19.rules
KERNEL=="ttyS0", SYMLINK+="serial0" GROUP="tty" MODE="0660"
KERNEL=="ttyAMA0", SYMLINK+="serial1" GROUP="tty" MODE="0660"
  1. and reload your udev rules: sudo udevadm control --reload-rules && sudo udevadm trigger
  2. change the group of the new serial devices
sudo chgrp -h tty /dev/serial0
sudo chgrp -h tty /dev/serial1
  1. The devices are now under the tty group. Need to add the user to the tty group and dialout group:
sudo adduser $USER tty
sudo adduser $USER dialout
  1. update the permissions for group read on the devices
sudo chmod g+r /dev/ttyS0
sudo chmod g+r /dev/ttyAMA0
  1. reboot

これらの手順1~10を経ると、Raspberry Pi OSの場合と同様に、Ubuntuでも次のコマンドでCO2濃度の値を取得できた。

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.1 LTS"

$ sudo python3 -m mh_z19
{"co2": 617}

root権限なしでMH-Z19Cの値を読む方法 (不採用)

MH-Z19へのアクセスを、sudoせずに、つまりroot権限を使わずに行う方法については、モジュールmh_z19の作者による次の記事がある。

この記事にしたがって、実際に/dev/serial0の権限設定を行ってから、--serial_console_untouchedオプションを付けてモジュールmh_z19を一般ユーザ権限で実行すると、MH-Z19の値を読めるようだ。

$ python3 -m mh_z19 --serial_console_untouched
{"co2": 1223}

しかし私の環境の場合、センサーへのアクセスを繰り返して値を読んだ時に、たまに何らかのエラーが出ることがあった (詳細は失念した)。このため今回は動作の安定性を重視し、root権限を使わないアクセスは諦めることにした。

3. CO2濃度が閾値を超えたら住人へ換気を促す

CO2濃度を常時監視できるようになったので、次は、リビングのCO2濃度が高まったら、住人へ、換気を促す仕組みを作る。促す際には部屋のスマートスピーカーをしゃべらせることにする。

以降の内容は、私の家の環境に完全に依存したものになってしまうことご了承ください。一部分でも参考になれば幸いです。

前提条件

今回用意する仕組みに必要な、前提条件はおよそ次の通り。

スクリプト類を作成

CO2濃度を取得するmh-z19_thingspeak.sh

root権限のcronを使ってサーバ上で24時間実行する前提の、bashスクリプトを作成した (ソースコードは最後に掲載)。次のような動作をする。

mh-z19_thingspeak.shを動かすため、root権限のcrontabを次のように設定している。

FILE_CO2=/tmp/co2_value.dat

*/1 * * * * timeout 10s 【設置ディレクトリ】mh-z19_thingspeak.sh >/dev/null; sleep 10; timeout 10s 【設置ディレクトリ】mh-z19_thingspeak.sh 【ThingSpeak_WRITE_API_KEY】 >${FILE_CO2}.tmp 2>&1; chmod o+r ${FILE_CO2}.tmp; mv ${FILE_CO2}.tmp ${FILE_CO2}

CO2濃度を監視するco2_alert.sh

一般ユーザ権限のcronを使ってサーバ上で毎日8時台~21時台に実行する前提の、bashスクリプトも作成した (ソースコードは最後に掲載)。次のような動作をする。

  • 第一引数をファイル名としてファイルを読み込み、その内容を最新のCO2濃度の値とする
  • CO2濃度≧警告値 (1500ppm) のとき
    • 状態が「ok」のとき
      • 状態を「alert」として記録する
      • ★『いま○○○○ppmなので換気して!』と言う音声ファイルを用意してgoogle-home-notifierで再生する
    • 状態が「alert」のとき
      • 状態「alert」が一定時間 (30分) 継続していたら再度★を実行する
  • CO2濃度≦安心値 (1000ppm) のとき
    • 状態が「ok」のとき
      • 何もしない
    • 状態が「alert」のとき
      • 状態を「ok」として記録する
      • 『○○○ppmに下がりましたので換気完了!』と言う音声ファイルを用意してgoogle-home-notifierで再生する
  • 各音声ファイルは1ppm単位の個別のものとし、VoiceTextで作成したものをローカルにキャッシュする

co2_alert.shを動かすため、一般ユーザ権限のcrontabを次のように設定している。

FILE_CO2=/tmp/co2_value.dat

*/1 8-21 * * * sleep 40; timeout 10s 【設置ディレクトリ】co2_alert.sh ${FILE_CO2} >/dev/null 2>&1

4. CO2濃度アラートを数日動かした結果

二酸化炭素濃度に関して

リビングのCO2濃度を測定して可視化したことで、気づいたこと。

  • リビングでのCO2濃度1500ppmという閾値は、我が家の暮らしぶりにおいて1日に3回ほど強制換気するきっかけとなるもの。冬場はこのぐらいの設定で良いかなと感じている
  • リビングの換気は、台所の換気扇を一定時間強めに稼働させることでも行えるようだ。なお、窓の上部に付いている換気小窓は、各部屋で常に1~2cmほど開けている
    • 台所の換気扇の電源をスマートコンセントで制御できれば、換気も自動化出来て良いだろうが、構造的には難しそうなので妄想レベルに留まる
  • 朝起床した後に寝室の空気がリビングに流れ込むと、リビングのCO2濃度が瀑上がりすることに驚いた。すなわちこれは、ほぼ締め切った状態の寝室のCO2濃度は、就眠中におそらく何千ppmという高い状態まで上昇していることを意味する

スマートスピーカーに喋らせる声の工夫に関して

Googleスマートスピーカーに喋らせる部分は、co2_alert.shを見ると分かるように、音声ファイルをffmpegで結合することで、それらしいジングル (キャッチーな音) が各音声ファイルの冒頭に入るよう工夫した。(なお、google-home-notifierの通常のテキスト→音声化を用いつつ、切れ目なくジングルを挿入する方法は思いつかなかった)

また、流暢な発声をするVoiceText Web APIには感情のパラメータもあるので、特に換気完了のお知らせはemotion=happiness, emotion_level=3と陽気な感じに設定し、アニメのキャラクターボイスのように溌剌としゃべってもらっている。

  • 「【ニュース速報のようなピポパポ音♪に続いて】二酸化炭素濃度が現在1523ppm。少し換気をしましょう!」
  • 「【明るいチャイム音♪に続いて陽気な声で】987ppmまで下がりました。換気完了です!」

アラートの通知のあり方として、通知音にどのような工夫ができるかを試すのは楽しかったし、今回の通知方法は家族の受けも良いようで、スマートスピーカー連携を作った甲斐がある。昨今のコロナ渦で注目されている換気の重要性を、私も含め家族へ身近なところから意識させることにつながるかな。

ソースコード

mh-z19_thingspeak.sh

#!/bin/bash

# mh-z19_thingspeak.sh

THINGSPEAK_APIKEY=$1
THINGSPEAK_FIELD='field4' # 各自のThingSpeakに合わせて要調整
THINGSPEAK_URL='https://api.thingspeak.com/update'

if [ `whoami` == "root" ]; then
	VALUE_CO2=`python3 -m mh_z19 | jq -r '.co2'`
else
	VALUE_CO2=`python3 -m mh_z19 --serial_console_untouched | jq -r '.co2'`
fi
echo ${VALUE_CO2}

if [ -n "${THINGSPEAK_APIKEY}" ]; then
	curl -s ${THINGSPEAK_URL} -X POST -d "${THINGSPEAK_FIELD}=${VALUE_CO2}" -H "X-THINGSPEAKAPIKEY: ${THINGSPEAK_APIKEY}" >/dev/null
fi

co2_alert.sh

#!/bin/bash

# co2_alert.sh
# usage: $0 <filename>

if [ $# != 1 ]; then
	exit 1
fi
CO2_NOW=$(cat "$1" | awk '{print $1}')
if [ ${CO2_NOW} -lt 1 ]; then
	exit 2
fi

CO2_ALERT=1500
CO2_OK=1000
SEC_ALERT=1800

TEXT_CO2_OK="${CO2_NOW}ppmまで下がりました。換気完了です!"
TEXT_CO2_ALERT="二酸化炭素濃度が現在${CO2_NOW}ppm。少し換気をしましょう!"
DIR_BASE='【設置ディレクトリ】'
DIR_SOUND="${DIR_BASE}/cache/"
mkdir -p ${DIR_SOUND}
FILE_SOUND_IN_OK="${DIR_BASE}/News-Accent01-1.mp3" # https://otologic.jp/free/se/news-accent01.html
FILE_SOUND_IN_ALERT="${DIR_BASE}/News-Alert03-1.mp3" # https://otologic.jp/free/se/news-alert01.html
TYPE_SOUND='wav'
FILE_SOUND_OUT="/tmp/co2.${TYPE_SOUND}" # ウェブサーバからアクセスできるように別途工夫 (例: symlink)
FILE_STATUS='/tmp/co2_status.dat'
TMP_FILE=$(mktemp --suffix=.wav -u)

# 鉄板のtmpfile処理 https://fumiyas.github.io/2013/12/06/tempfile.sh-advent-calendar.html
func_atexit() {
	#[[ -n ${DIR_TMP-} ]] && rm -rf "${DIR_TMP}"
	[[ -n ${TMP_FILE-} ]] && rm -f "${TMP_FILE}"
}
trap func_atexit EXIT
trap 'rc=$?; trap - EXIT; func_atexit; exit $?' INT PIPE TERM

func_savevoice() {
	TMP_FILENAME=$1
	TMP_TEXT=$2
	TMP_EMOTION=$3
	TMP_EMOTIONLEVEL=$4
	VOICETEXT_APIKEY='【VoiceText API KEY】'
	if [ -z "${TMP_EMOTION}" ]; then
		curl "https://api.voicetext.jp/v1/tts" \
			-o "${TMP_FILENAME}" \
			-u "${VOICETEXT_APIKEY}:" \
			-d "text=${TMP_TEXT}" \
			-d "speaker=hikari" >/dev/null
	else
		curl "https://api.voicetext.jp/v1/tts" \
			-o "${TMP_FILENAME}" \
			-u "${VOICETEXT_APIKEY}:" \
			-d "text=${TMP_TEXT}" \
			-d "speaker=hikari" \
			-d "emotion=${TMP_EMOTION}" \
			-d "emotion_level=${TMP_EMOTIONLEVEL}" >/dev/null
	fi
}
func_voice() {
	CO2_STATUS=$1
	TEXT_CO2=$2
	if [ "${CO2_STATUS}" == 'ok' ]; then
		FILE_SOUND_IN=${FILE_SOUND_IN_OK}
		EMOTION='happiness'
		EMOTION_LEVEL=3
	else
		FILE_SOUND_IN=${FILE_SOUND_IN_ALERT}
		EMOTION=''
		EMOTION_LEVEL=
	fi
	HASH_TEXT=`echo ${TEXT_CO2} ${FILE_SOUND_IN} ${EMOTION} ${EMOTION_LEVEL} | md5sum | awk '{print $1}'`
	if [ -e "${DIR_SOUND}/${HASH_TEXT}.${TYPE_SOUND}" ]; then
		:
	else
		func_savevoice "${TMP_FILE}" "${TEXT_CO2}" ${EMOTION} ${EMOTION_LEVEL}
		ffmpeg -y -loglevel quiet -i "${FILE_SOUND_IN}" -i "${TMP_FILE}" -filter_complex [0:a][1:a]concat=n=2:v=0:a=1 "${DIR_SOUND}/${HASH_TEXT}.${TYPE_SOUND}" >/dev/null
	fi
	rm "${FILE_SOUND_OUT}"
	cp -p "${DIR_SOUND}/${HASH_TEXT}.${TYPE_SOUND}" "${FILE_SOUND_OUT}"
	URL_BASE='http://【google-home-notifierサーバ】:8091/google-home-notifier/?text='
	wget -q -O - "${URL_BASE}http://【自宅LAN内のウェブサーバ】/co2.${TYPE_SOUND}"
}
func_savestatus() {
	echo $1 >${FILE_STATUS} # ok, alert
}
func_durationstatus() {
	TMP_NOW=`date +"%s"`
	TMP_MOD=`date +"%s" -r ${FILE_STATUS}`
	if [ $((TMP_NOW - TMP_MOD)) -ge ${SEC_ALERT} ]; then
		echo 1
	else
		echo 0
	fi
}

if [ -e "${FILE_STATUS}" ]; then
	CO2_STATUS=`cat ${FILE_STATUS} | awk '{print $1}'`
else
	func_savestatus 'ok'
	exit 0
fi
if [ ${CO2_NOW} -ge ${CO2_ALERT} ]; then
	# CO2の値≧警戒値
	if [ "${CO2_STATUS}" == 'ok' ]; then
		# 警告スタート
		func_savestatus 'alert'
		func_voice 'alert' "${TEXT_CO2_ALERT}"
	else
		# 警告続行中
		TMP_FLAG=`func_durationstatus`
		if [ "${TMP_FLAG}" -eq 1 ]; then
			func_savestatus 'alert'
			func_voice 'alert' "${TEXT_CO2_ALERT}"
		fi
	fi
elif [ ${CO2_NOW} -le ${CO2_OK} ]; then
	# CO2の値≦安心値
	if [ "${CO2_STATUS}" == 'ok' ]; then
		# OK継続中
		:
	else
		# OKスタート
		func_savestatus 'ok'
		func_voice 'ok' "${TEXT_CO2_OK}"
	fi
fi