ArduinoとESP32で空気質モニタリングアプリを作る(1)- センサー紹介編
はじめに
最近、IoT関連のアプリケーションにすっかりハマっていて、Arduinoや各種センサーをたくさん買い込んでいる。手元にあるArduinoだけでも、Arduino Uno、Arduino Mega2560、Arduino nanoが3つもある。時間があるときにいろいろ触って、面白いアプリケーションが作れないか試してみようと思ったのだ。ついでに高校時代に学んだ電子回路やハードウェアの動作を復習する良い機会でもある。
ちょうど会社で社内ハッカソンが開催され、テーマの制限もあまりなかったため、この期間を利用して以前から作りたかった「空気質モニター」を実装してみた。空気質とは言っても、実際に出来上がったものは二酸化炭素濃度と温湿度のモニタリングだけだが、センサーさえ用意すれば他の測定項目を追加するのも大した問題ではない。
このアイデアが浮かんだ主な理由は、二酸化炭素の濃度が人間の思考力や生産性に本当に影響を与えるからだ。会議室に閉じこもっていると、急に頭がぼーっとしたり思考がまとまらなくなったりした経験は誰しもあるはずだ。それは二酸化炭素濃度が高くなりすぎたことが原因である可能性が非常に高い。換気が不十分な密閉空間では二酸化炭素濃度の上昇スピードが極めて速く、数分で2000ppmを超えてしまうこともある。
実装そのものに加えて、ライブラリをただ適当に組み合わせて完成させるやり方が個人的にあまり好きではないため、できるだけ詳細な部分まで踏み込んで調べるようにした。そうしないと、「ライブラリを入れて終わり、裏で何が起きているのかさっぱり分からない」という虚しさが残ってしまうからだ(実際の実装ではライブラリを使っているが、少なくとも仕組みを理解した上で気持ちよく使えている笑)。
本シリーズでは以下のテーマを取り上げる予定だ:
- センサー紹介編 - DHT11とMH-Z14A
- データ通信編 - UART
- Arduino ハマりどころ編:実装時に直面した問題の解説。多くは落とし穴というより、Arduinoやハードウェアへの理解不足が原因だった
- WiFi編:デバッグ時間を節約するため、最初からWiFiとBluetooth機能を搭載しているESP32開発ボードを追加購入した
- MQTT編:他のデバイスにデータを送信するため、軽量でコンパクトな通信プロトコルであるMQTTを採用した
- Grafana / Web編:データベースに保存したデータは、やっぱりかっこよく可視化したい!ここではGrafana + Prometheus、そしてSvelteを使ってデータを表示した。
アーキテクチャ
説明しやすいように、全体のアーキテクチャを図にすると以下のようになる:

- Arduino UnoがCO2センサーにコマンドを送信し、CO2センサーがArduinoにデータを返送する(両者はUARTで通信)
- Arduino UnoがCO2データをESP32に送信する(こちらもUARTを使用)
- DHT11から温湿度データを取得し、ESP32に送信する
- ESP32が温湿度データとCO2データをWiFi経由でMQTTブローカーに送信する
- イベントをサブスクライブするアプリケーションは2つ:分析サーバーとAppサーバー
- 分析サーバーはデータをPrometheusに送信し、Grafanaでデータを可視化する
- Appサーバーはデータを受信してデータベースに保存し、外部向けにAPIを提供する
- 二酸化炭素が一定濃度を超えた場合、Slack通知を送信する
全体としては大体このような構成になっている。ここで想定されるいくつかの疑問に答えておこう:
1. なぜわざわざArduino Unoを併用したのか?ESP32ボード1枚で解決できたのでは?
当初の考えとしては、ESP32をメディエーター(仲介役)のように位置づけ、アーキテクチャ全体をすっきりさせようとしたためだ。ただ、実装が終わってみるとその必要はあまりなかったようにも思える。唯一注意すべき点として、Arduino標準のライブラリの中にはESP32では動かないものがあるということだ。例えば今回の実装で使ったSoftwareSerial(デジタルピンをTX/RXピンとして使えるようにするライブラリ。今後の記事で詳しく触れる)が挙げられる。このライブラリがない場合、内蔵のHardwareSerialを使うしかなく、UART通信は1系統しか行えなくなってしまう。
もうひとつの理由は、見た目がなんとなく玄人っぽいからだ。Arduinoを使わないとワクワクしない。
2. DHT11をArduino UnoではなくESP32に接続したのはなぜか?
前述の通り、Arduino Unoに接続した場合はUART経由でESP32にデータを送る処理が別途必要になる。手間を省くために直接ESP32に接続した。幸いライブラリ自体もESP32をサポートしていた。もちろんArduino Uno側で処理しても問題はない。
3. なぜMQTTを選んだのか?
このプロトコルは非常に軽量で、他のプロトコルに比べてオーバーヘッドが少ないため、CPUが遅くメモリも潤沢ではないIoTのような環境に非常に適しているからだ(もちろんその分、可用性などは割り切る必要がある)。
4. なぜデータベースを2つに分けたのか?
鋭い読者なら、分析用DBとApp用DBが分かれていることに気づいただろう。1つ目の理由は僕がPrometheusにあまり慣れておらず、自分でクエリを書いてデータを取得しようとすると時間がかかりそうだったからだ。もう1つは、PostgreSQLのSQL構文はNode.jsとの統合が充実しており、実装が手軽だったからである。Prometheusのクエリについても今後もっと触ってみたいと思う!
成果
ネット上で有名な空気質モニタリング製品といえばAWAIRだろう。洗練されたUIを備え、温湿度や二酸化炭素だけでなく、化学物質やPM2.5まで測定できる。しかし価格は決して安くなく、1台149米ドル(台湾ドルで約4,470元)ほどする。それに対して今回の実装にかかった費用は以下の通りだ:
- DHT11 温湿度センサー:約60元
- MH-Z14A CO2センサー:約800元
- ESP32 開発ボード:約200元
- Arduino Uno:約800元(純正品。Nanoで代用すればもっと安くなる)
合計:1,860元(約6,500円)
もちろん見た目はかなり無骨だが笑、コードを少し手直しすればGoogle Homeと連携させたり、簡単なスマホアプリを作ったりと、さまざまな応用ができるはずだ。
回路
少し整理すれば、なんとかケースに収まりそうだ。
Grafana

Webインターフェース
UIはかなりシンプルだが、ここではデモとして作成した。

センサー紹介:DHT11とMH-Z14A
MH-Z14A CO2センサー
二酸化炭素濃度を取得するには、まずセンサーが必要になる。ネットで調べたところ、二酸化炭素濃度を測定するにはNDIR(Non Dispersive Infra Red、非分散型赤外線)と呼ばれる方式が使えることが分かった。
これは、気体が特定の波長の赤外線を吸収する特性を利用して気体濃度を算出する仕組みだ。二酸化炭素が赤外線を吸収する波長域は4.26μmであるため、この波長に絞って計算することで濃度を割り出すことができる。計算式自体は今回の本題ではないので割愛するが、特筆すべきはこのセンサーの使い方だ。
ネットで探してみると中国語の情報が非常に少なかったため、ここで簡単に説明しておきたい。データシートと併せて読むと分かりやすいはずだ。ネット上には様々なデータシートが出回っているが、これが現時点で一番詳細だった。
このデータシートからいくつかのことが分かる:
-
MH-Z14A自体はUARTを介して通信を行い、コマンドコードを送信するとレスポンスが返ってくる。コマンドには二酸化炭素濃度の測定だけでなく、ゼロ点調整や測定範囲の変更なども用意されている。
コマンドコード 機能(原文 / 和訳) 備考 0x86 Gas concentration / 二酸化炭素濃度の測定 0x87 Calibrate zero point value / ゼロ点キャリブレーション このセンサーには3つのゼロ点校正方法があり、コマンド送信はその1つ 0x99 Calibrate span point value / 二酸化炭素検出範囲の変更 0x79 Start/stop auto-calibration function of zero point value / 自動キャリブレーション機能の有効化(または停止) 工場出荷時、このセンサーはデフォルトで自動校正が有効になっている -
MH-Z14Aの出力は3種類のタイプをサポートしている(非常に便利だ笑)。それぞれ
UART、PWM、そしてAnalogだ。好みの出力を選んで二酸化炭素濃度を取得できるが、今回は手軽で数値の余計な変換処理も不要なUARTを採用した。
DHT11 センサー
DHT11は温湿度センサーだ。低消費電力で構造がシンプルであり、ピンは3つしかない。VccとGNDをそれぞれ接続するだけで動作を開始できる。ここで少し特殊なのがDHT11のデータ転送方式だ。DHT11にはデータバス用のピンが1つしかない。1本のピンだけでどのように温湿度データを送るのだろうか?
その答えは時間だ。実際、ハードウェアの世界では多くのデータ通信が精密な時間制御に依存している。例えば8bitのデータを取得したい場合、2ms間隔を1単位として、2msごとに1回読み取る処理を16ms行えば、8bitのデータを取得できるわけだ。
ただし、これを実現するにはいくつかの問題を考慮しなければならない:
- 相手はいつデータの送信を開始するのか?つまり、自分の2msのカウントはいつから始めればよいのか?
- 伝送プロセス中にエラー(環境ノイズ、一時的なショート、パケットロスなど)が発生した場合、どのように処理すべきか?
これらはすべてデータシートに記載されている。詳しく見てみよう。
Single-bus data format is used for communication and synchronization between MCU and DHT11 sensor. One communication process is about 4ms. Data consists of decimal and integral parts. A complete data transmission is 40bit, and the sensor sends higher data bit first. Data format: 8bit integral RH data + 8bit decimal RH data + 8bit integral T data + 8bit decimal T data + 8bit check sum. If the data transmission is right, the check-sum should be the last 8bit of “8bit integral RH data + 8bit decimal RH data + 8bit integral T data + 8bit decimal T data”
データシートの説明によると、通信の各プロセスには約4msかかり、データ構成は「8bit相対湿度(整数部)+ 8bit相対湿度(小数部)+ 8bit温度(整数部)+ 8bit温度(小数部)+ 8bitチェックサム」となっている。
このチェックサムが2つ目の疑問に対する答えとなる。計算方法は全データを合計した後の下位8bitを取るというものだ(humidity + humidityDec + temp + tempDec != parity)。
これにより、計算結果がチェックサムと一致しなければ、伝送プロセス中に問題が発生したと判断できる。もちろん、伝送中のエラーによって偶然チェックサムが一致してしまう可能性もゼロではないが、その場合は諦めるしかない(通常の環境下であればその確率は極めて低いはずだ)。
DHT11はどのようにデータを送信するのか?
1つ目の疑問に答えるため、データシート内の図を見てみよう。通信全体のプロセスが説明されている。

- 温度測定を開始する際(ArduinoがDHT11にデータを要求するとき)、まずHIGH -> LOWの信号を送り、少なくとも18ms間それを維持する。その後、信号をLOW -> HIGHに切り替える。Arduinoでは
delay()とdigitalWriteの2つの関数を使えば実現できる。(追記:なぜDHT11がこの信号を検知するのにこれほど長い時間が必要なのか、僕にはあまりよく分かっていない。ハードウェアに詳しい方がいればぜひ教えてほしい) - このときMCUは20us経過後に信号をHIGHからLOWに戻す(ここでArduinoのピンをINPUTに変更するのを忘れないこと。
pinMode(PIN, INPUT)で設定できる)。 - この時点で信号はLOWになり、80us待機した後にHIGHに変化する。そしてさらに80us経過後、信号は再びLOWになる。
- データ送信が開始される(次の図へ続く)。

ここからいよいよデータの送信が始まる!データシートには0と1の判別方法が記載されている。図に示されているように、電圧がHIGHレベルを26〜28usの間維持していれば、応答データは0を意味する。電圧がHIGHレベルを70us維持していれば、応答データは1を表す。各ビットの間には電圧がLOWレベルになり、50us持続した後に再びHIGHレベルになる。このような設計になっているのは、各ビットの間隔を明確に区切ることで、Arduinoが読み取る際の誤判定を防ぐためだろう。
この時間を計測するには、Arduinoのmicros()関数を利用すればよい。簡単な実装例は以下のようになる:
void blockUntil(int state)
{
while (digitalRead(PIN) != state)
{
}
}
void main() {
auto timeS = micros();
blockUntil(LOW);
auto timeE = micros();
auto result = timeE - timeS > 60 ? 1 : 0;
}
ここでデータシートに書かれている50usではなく60usを使っているのは、時間がギリギリだと読み取りエラーが発生することがあるからだ。以下は僕の推測だが、有識者の方のご意見を伺いたい。
僕たちがコードを実行する際、機械語にコンパイルされるため、CPUの実行時にいくつかの命令サイクル(instruction cycle)を消費する。その積み重ねによって、ここでの50usの判定にズレが生じているのではないだろうか?ただ、もしそうなら50usをより短く調整すべきであって、大きくするべきではないはずだ。そう考えると、もう1つの可能性としては単にDHT11の応答が遅いだけなのかもしれない。
このプロセス全体を完了すれば、データが取得できるはずだ!今回の実装ではライブラリを使って済ませたが、機会があればライブラリを使わずにこの機能全体をゼロから実装する方法についても別の記事で紹介したいと思う。データシートを読む手間さえ惜しまなければ、実装自体はそれほど難しくないはずだ。
まとめ
今回は今回のアプリを実現するために必要なセンサーとその用途を紹介し、データシートを一緒に読み解いてきた。これで2つのセンサーがどのようにデータを伝送しているのか、ある程度イメージが掴めただろう!次回は、ハードウェア同士の一般的な通信手段である「UART」について解説し、実装時に遭遇した問題と最終的な解決策(要するにライブラリを使っただけなのだが!)を共有する予定だ。
関連記事
- 測定が目標になるとき:窓税からPull Request数まで かつて僕は小さなツールを自作し、四半期で自分がどれだけPRに貢献したか、レビューコメントをどれだけ残したか、チケットをどれだけ消化したかを集計して、上司にアウトプットを証明しようとしたことがある。上司は淡々と、評価はアウトプットだけで見るものではないと言った。数年後、僕はようやく理解した――測定が目標になるとき、それはもはや良い測定ではなくなるのだ。英国の窓税、ハノイのネズミ駆除の報奨金から、現代のPR数による開発者評価に至るまで、そのメカニズムはまったく同じだ。
- Cloudflare Images を画像ストレージ・変換ソリューションとして使う ウェブページに画像を1枚置くのはフロントエンドにとって最も簡単なことだが、リサイズや各種フォーマットの生成、さらにはトラフィックの負荷に耐えることまで完璧にやろうとすると、実際には一つの包括的なソリューションが必要になる。僕はその後、すべて Cloudflare Images に任せるようになり、オリジナル画像1枚だけを渡すようにしている。
- もう AWS Access Key を使うのはやめよう Access Key は AWS において見落とされがちなセキュリティリスクだ。OIDC と IAM Role を組み合わせることで、GitHub Actions にシークレットを一切保持させることなく、安全に AWS リソースを操作できるようにする。
- データベース主キー:AUTO_INCREMENT、UUID、そしてUUIDv7 バックエンド開発で度々直面する主キーの決定。auto incrementを使うべきか、それともUUIDか?衝突への懸念は?UUIDv7とcreated_at + インデックスの性能差はどれほどか?実際に2,000万件のデータで検証したベンチマークと設計上の意思決定を解説する。