CO2 濃度を測ってみた 〜SORACOM Harvest と Wio LTE ならとってもかんたん!〜
2018年4月26日に SORACOM Technology Camp が開催されました。⛺
ご来場いただいた方はお気づきになられたかもしれませんが、社員が SORACOM のサービスを使って作ったデモが並んでいるコーナーがあったと思います。
これは、「SORACOM を使うとこんなに簡単に IoT のシステムを組めるよ!」という例を見ていただくため、また、お客様はエンジニアの方が中心ということで、「自分も何か作ってみたい」という気持ちを少しでも刺激することができたら、そんな思いで用意したデモでした。
マーケティングチームからエンジニアチームに「デモを作って」という依頼が舞い込んだのが 4 月 9 日でした。
それを受けて、私は以前から個人的にやってみたいと思っていながらも時間がないことを理由にやらずにいた、CO2(二酸化炭素)濃度の測定と見える化をしてみることにしました。
なぜ CO2 濃度を測ってみたくなったの?
以下のような記事をたまたま同時期にいくつか読んで、CO2 の濃度が生産性に大きな影響を与えるということを知ったので、まずは自宅の仕事部屋の CO2 濃度が気になったからです。
また、ソラコムは会議自体が少ない方なのですが、たまに大人数で会議をしていると時間が経つに連れてだんだん生産性が低下していっている気がしていました。(これはソラコムに限ったことではなく、過去の勤め先などでの会議でとても身に覚えのあることです)
これまでは、単に疲れて集中力が途切れるせいかと思っていましたが、実は会議スペースの CO2 濃度にも何か因果関係があるのではないだろうか、もしあるとしたら会議のときに CO2 濃度を測定しておいて濃度が高まってきたら早めに換気を促すなどの対策を取れば、多少は会議の生産性の低下に抵抗しうるのではないか、そんな思いが生じたからです。
なぜ買ってこないで作るの?
作りたかったからです。エンジニアの端くれなので、仕方ないですよねぇ。
というのは半分冗談として。
たとえば市販品で以下のような物があります。

NETATMO(ネタトモ) ウェザーステーション スマホ対応 温度・湿度・気圧・二酸化炭素・騒音を測定 【日本正規代理店品・保証付き】
- 出版社/メーカー: Netatmo
- 発売日: 2013/10/10
- メディア: エレクトロニクス
- この商品を含むブログ (3件) を見る

coxfox(コックスフォックス) 二酸化炭素濃度計 二酸化炭素濃度測定器具 【付属品:ACアダプター、単三形アルカリ乾電池×4本、取扱説明書】 GC-02
- 出版社/メーカー: coxfox(コックスフォックス)
- メディア: スポーツ用品
- 購入: 2人 クリック: 16回
- この商品を含むブログ (5件) を見る
探せばもっと安いものもあるかもしれませんが、1万円超えというのはちょっと試しに買ってみるには高い感じがします。
一方、ふと気づくと自分の手元にはすでに Wio LTE がある・・・(こちらも1万円超え*1ですが、これはいろいろな目的に使えるので、と自分に言い訳)

- 出版社/メーカー: スイッチサイエンス
- メディア:
- この商品を含むブログを見る
Wio LTE の Grove コネクタに接続できる CO2 センサーを買ってきてつなぎ、あとは SORACOM Harvest にデータを投げればサクッと目的が達成できるのではないか、なぜもっと早くやらなかったんだろうと後悔しました。
早速 Grove システム対応の CO2 センサーを検索して見つけたのがこちら:
あれあれ?
・・・思ったより・・・高いぞ・・・😨
そんなことを社内の Slack でつぶやいた時に教えてもらったのがこのあたりの記事:
MH-Z19 という格安 CO2 センサを読んでみた | tech - 氾濫原
ArduinoでCO2濃度を計測する(MH-Z19) - はらぺこらいおん
ふむふむ。MH-Z19 というセンサーを Aliexpress で購入すればよいのか。
Aliexpress で探してみると、20 ドルくらいで買えそうなものを見つけました。 (ただし型番は MH-Z19 B と いうもの)
用意したもの
- CO2 センサー MH-Z19B
- Wio LTE
- SORACOM Air for Cellular SIM (ナノサイズ)
- Wio LTE と CO2 センサーを接続するための Grove コネクタケーブル
- ジャンパーケーブル
- ブレッドボード
- 不要な(切っても良い) USB ケーブル(片方が USB A オス)
- ワイヤーストリッパーがあると便利。無ければカッターで慎重に頑張る
- はんだ付けの道具
接続
まず MH-Z19B を動作させるための配線を行いましょう。
VIN には 5V を供給する必要があります。
しかし、ここでいきなりつまづきます。Wio LTE から Grove コネクタ経由で供給される電圧は 3.3V なのです。
仕方ないから昇圧させるか・・・とも思いましたが、ふと USB バスパワー(VBUS)があることに気づきました。
Wio LTE の回路図 を見てみると、VBUS が TP として出ていそうです。

TP13 は、Wio LTE の基板のこのあたりのようです:

(マイクロ USB コネクタのすぐ裏の、4つ並んでいる金属の丸い端子のうち、上の写真の状態では一番下のもの)
ここからセンサーに電源を供給できるかなと思ってジャンパー線をはんだ付けしようとしたのですが、写真をご覧いただいておわかりかもしれませんが私の技量ではうまく行かず断念しました。
それで結局どうしたかというと、もう一本別の USB ケーブルを用意して、VBUS だけを直接取り出すことにしました。
ということで、「不要な USB ケーブル」の出番です。電源供給元に挿すのは USB A のオスの方なので、そうでない方のコネクタを切り落とします。
次に、切り落とした部分から1cmくらいをワイヤーストリッパーで外側の被覆を剥くと、中から線が4本出て来るはずです。 どの線が VBUS かをテスターなどで調べましょう。 私が剥いたケーブルはちゃんと VBUS が赤、GND が黒だったので分かりやすかったです。 ネットでは、安物のケーブルでは赤黒が逆だったケースなどが報告されているので気をつけましょう。
VBUS / GND それぞれのケーブルもさらに数mm分の被覆を剥いて、中の金属線をむき出しにします。 私の使ったケーブルは撚り線になっていたので、ブレッドボードに挿しやすいようにジャンパー線にはんだ付けしました。 VBUS と GND がショートすると危険なので必ず絶縁しておきましょう。
私の場合はこのようなケーブルが出来上がりました:

(熱収縮チューブの径が合ってないのが残念なところ・・・)
この USB ケーブルと MH-Z19B を、ブレッドボードを使って接続します。

次に Wio LTE と MH-Z19B を接続します。

Wio LTE 側のコネクタは D38 と書かれたコネクタを使います。(D38 はスケッチで明示的に ON にしなくてもデフォルトで ON になるそうです)
Grove ケーブルの黄色い線を MH-Z19B の PWM に接続します。 Grove ケーブルの黒い線を GND につなぎます。 赤の線と白の線は使いません。
あとは Wio LTE にスケッチを書き込んで、SORACOM Harvest へデータを投げ込めばデモの完成です。 スケッチ例はこの記事の末尾に載せておきます。 やっていることは単純で、センサーから出力される PWM の ON の時間と OFF の時間(トータルで約1秒)を読み取って、比率に応じて ppm の値に換算、それを1分間の平均値を取って毎分 Harvest に UDP で送信するのみです。
なお今回のデモでは、このような 2 又の USB ケーブル を使って、1つのモバイルバッテリーから Wio LTE と MH-Z19B へ給電しました。

当日の展示の様子

まとめ
会場に設置当初は 450ppm 程度で推移していました。 息を吹きかけるとグラフは急峻な立ち上がりを見せ、しばらくすると元の水準にもどっていました。 セッションの合間の人が多い時間帯は少し ppm 値が高くなり、セッションが始まって人が少なくなると ppm 値も下がっていくという挙動を観測することができました。
今度は社員が集まるミーティングなどで実際に稼働させてみたいと思います。
また、他のエンジニアのみなさんはそれぞれ Wio LTE をケースに入れていたので私も何かのケースに収めてみたいなと思いました。
SORACOM Harvest は本当に簡単で、設定することと言えば SIM のグループで Harvest を ON にするだけです。 それで雑にデータを投げ込むとグラフにしてくれたりするという超絶スグレモノです。サーバーサイドの設定が不要で、これはもうチートと言っても過言ではないくらいプロトタイピングが加速されます。 デバイス側の工夫などに、より時間を使うこともできるようになりますよね。
しかも!SORACOM Harvest で蓄積したデータがあると、SORACOM Kaleidoscope を楽しむこともできるのです! これはまさに一石二鳥!
結論: SORACOM Harvest 最強すぎる
スケッチ
#include <WioLTEforArduino.h>
#include <stdio.h>
#define ARRAY_LENGTH(a) (sizeof(a) / sizeof(a[0]))
#define PWM_PIN (WIOLTE_D38)
WioLTE Wio;
typedef struct hist {
int v;
unsigned long t;
} pwm_history;
pwm_history h[3];
float ppm_history[60];
unsigned long last_report_timestamp = 0;
void setup() {
delay(200);
Wio.LedSetRGB(16, 0, 0);
SerialUSB.println("");
SerialUSB.println("--- START ---------------------------------------------------");
SerialUSB.println("### I/O Initialize.");
Wio.Init();
SerialUSB.println("### Power supply ON.");
Wio.PowerSupplyLTE(true);
delay(5000);
Wio.LedSetRGB(16, 16, 0);
SerialUSB.println("### Turn on or reset.");
if (!Wio.TurnOnOrReset()) {
SerialUSB.println("### ERROR! ###");
return;
}
Wio.LedSetRGB(4, 4, 16);
SerialUSB.println("### Connecting to \"soracom.io\".");
delay(5000);
if (!Wio.Activate("soracom.io", "sora", "sora")) {
SerialUSB.println("### ERROR! ###");
return;
}
Wio.LedSetRGB(0, 16, 0);
delay(5000);
Wio.LedSetRGB(16, 16, 16);
pinMode(PWM_PIN, INPUT);
}
void loop() {
int v = digitalRead(PWM_PIN);
unsigned long t = micros();
if (v != h[2].v) {
pwm_push_history(v, t);
}
//SerialUSB.println("h[0]{ v = " + String(h[0].v) + "}, t = " + String(h[0].t) + ", h[1]{ v = " + String(h[1].v) + ", t = " + String(h[1].t) + "}, h[2]{ v = " + String(h[2].v) + ", t = " + String(h[2].t) + "}");
if (pwm_one_cycle_completed()) {
unsigned long th = h[2].t - h[1].t;
unsigned long tl = h[1].t - h[0].t;
pwm_init_history();
float ppm = 5000 * (th - 0.002) / (th + tl - 0.004);
ppm_push_history(ppm);
SerialUSB.println("th = " + String(th) + ", tl = " + String(tl) + ", PPM = " + String(ppm));
}
}
void pwm_init_history() {
for (int i = 0; i < ARRAY_LENGTH(h); i++) {
h[i].v = HIGH;
h[i].t = 0;
}
}
bool pwm_one_cycle_completed() {
return h[0].v == LOW && h[1].v == HIGH && h[2].v == LOW;
}
void pwm_push_history(int v, unsigned long t) {
for (int i = 0; i < ARRAY_LENGTH(h) - 1; i++) {
h[i].v = h[i+1].v;
h[i].t = h[i+1].t;
}
h[ARRAY_LENGTH(h) - 1].v = v;
h[ARRAY_LENGTH(h) - 1].t = t;
}
void ppm_push_history(float ppm) {
for (int i = 0; i < ARRAY_LENGTH(ppm_history) - 1; i++) {
ppm_history[i] = ppm_history[i + 1];
}
ppm_history[ARRAY_LENGTH(ppm_history) - 1] = ppm;
unsigned long now = millis();
if (last_report_timestamp + 60 * 1000 < now) {
float avg = get_average_ppm();
post_to_harvest(avg);
last_report_timestamp = now;
}
}
void post_to_harvest(float ppm) {
SerialUSB.println("### Open.");
int connectId = Wio.SocketOpen("harvest.soracom.io", 8514, WIOLTE_UDP);
if (connectId < 0) {
SerialUSB.println("### ERROR! ###");
goto err;
}
SerialUSB.println("### Send.");
if (!Wio.SocketSend(connectId, String(ppm).c_str())) {
SerialUSB.println("### ERROR! ###");
goto err;
}
SerialUSB.println("### Receive.");
int length;
char data[100];
do {
length = Wio.SocketReceive(connectId, data, sizeof (data));
if (length < 0) {
SerialUSB.println("### ERROR! ###");
goto err;
}
} while (length == 0);
SerialUSB.print("Receive:");
SerialUSB.print(data);
SerialUSB.println("");
SerialUSB.println("### Close.");
if (!Wio.SocketClose(connectId)) {
SerialUSB.println("### ERROR! ###");
goto err;
}
err:
return;
}
float get_average_ppm() {
float sum = 0;
for (int i = 0; i < ARRAY_LENGTH(ppm_history); i++) {
sum += ppm_history[i];
}
return sum / ARRAY_LENGTH(ppm_history);
}
*1:SORACOM User Console では定価は 9,800 円ですが、税や送料を考えると1万円を超えます