最近某感染症の影響もあって換気が重要になってきたのでCoreInkを使って換気の目安を表示する環境モニタを作ってみました
ソースはgithubを参照ください
coppercele/CoreInkEnvMonitor
https://github.com/coppercele/CoreInkEnvMonitor
使用するデバイス
M5Stack CoreInk 開発キット(1.5インチ Einkディスプレイ) - スイッチサイエンス
https://www.switch-science.com/catalog/6735/
M5シリーズで電子ペーパーをディスプレイに使用した比較的小型のモデル
M5Paperという大型のモデルもある
電子ペーパーを使用してるので低消費電力で情報を表示することができる
TVOC/eCO2 ガスセンサユニット(SGP30) - スイッチサイエンス
https://www.switch-science.com/catalog/6619/
比較的安価なCO2ガスセンサです
H2濃度からCo2の近似値を表示するもので誤差15%とあまり正確ではありませんが換気の目安を通知する程度なら十分かと思います
M5StickC ENV Hat(DHT12/BMP280/BMM150搭載)--在庫限り - スイッチサイエンス
https://www.switch-science.com/catalog/5755/
気温、湿度、気圧を測定できるHATです
現在は廃盤なので下記のENV HAT IIを使用してください
M5StickC ENV II Hat(SHT30/BMP280/BMM150搭載)
https://www.switch-science.com/catalog/6559/
CoreInkのライブラリを更新する
Arduino IDEのライブラリマネージャに登録されているCoreInkライブラリ(0.0.1)はRTCの関数がtypoされていたりするのでgithubからzipを落として最新(開発版?)にした方が良いと思います
(追記)ライブラリマネージャのCoreInkライブラリが0.0.2に上がっていましたので削除しました
デバイスを接続する
素直にENV HATをM5StickC互換の上部コネクタに、TVOCガスセンサを下部のGroveに接続すればいいはずなのですが、
「TVOCガスセンサをGroveに接続するとバッテリー電圧が高めに出る」という問題があるので背面の接続端子に接続します(詳細不明なので誰か解析してください)
デュポン(QI)コネクタを使って背面のG25,26に接続しました
バッテリー電圧を気にしない時は普通にGroveに接続してもいいと思いますが、
Grove接続をしてWire1をガスセンサに使おうとすると
RTCもWire1を使うためバッティングして片方が動かなくなってしまうという問題もあります
回避するためにはどちらかを使うたびにWire1を初期化しないといけません
裏のG25,G26に接続するとHATとUnitがWireで両方使えるのでそちらの方が便利かと思います
##気温、湿度を取得する##
ENV HATのexampleの通りに気温、湿度を取得します
ENV HAT IIの場合はSHT30に読み替えてください
#include "DHT12.h"
DHT12 dht12(&Wire);
float tmp = dht12.readTemperature();
float hum = dht12.readHumidity();
#include "SHT3X.h"
SHT3X sht30;
float tmp = 0.0;
float hum = 0.0;
if(sht30.get()==0){
tmp = sht30.cTemp;
hum = sht30.humidity;
}
https://github.com/m5stack/M5StickC/tree/master/examples/Hat/ENV
自分はExample同梱の.h,.cppファイルを使いましたが、
気になるようでしたら最新のライブラリを使うとよいかもしれません
二酸化炭素濃度を計測する
SGP30はexampleのコードもそうなってるのですがセンサを温める時間が必要らしくて18秒ほど400ppm(測定最低値)を出力しつつ基準値(baseline)を決定してそこからの相対値を出力し始めます
通常はつけっぱなしで測定するので問題になりませんが、
電子ペーパーを使用したCoreInkでは測定後電子ペーパーに結果を表示してから電源オフ、一定時間後にRTCで再起動してバッテリーの消費を抑えたいので気を付けないといけません
int i = 30;
long last_millis = 0;
Serial.print("Sensor init\n");
while (i > 0) {
if (millis() - last_millis > 1000) {
last_millis = millis();
i--;
if (sgp.IAQmeasure()) {
Serial.printf("%d:", calibrationNum - i);
Serial.print("eCO2 "); Serial.print(sgp.eCO2); Serial.print(" ppm\t");
Serial.print("TVOC "); Serial.print(sgp.TVOC); Serial.println(" ppb");
if (sgp.TVOC != 0 || sgp.eCO2 != 400) {
break;
}
}
}
}
安全のために30秒初期化するようにして最低値の400以外を測定したらループを抜けるようにしました
##キャリブレーションを実装する##
こちらを参考にさせていただいたのですが、
SGP30/SCD30によるCO2濃度測定 - hogehoge, world.
https://tomoto335.hatenablog.com/entry/co2-sensors
SGP30にはオートベースライニングという機能があり、
測定中に最低値を検出するとベースラインを更新して400ppmと表示するCO2濃度のベースラインを下げていきます
これはつけっぱなしで換気が起きる環境ではよいのですがCoreInkの電子ペーパーを生かして数分毎の間欠動作をさせようとした場合
1000ppmの環境で初期化すると1000ppmをベースラインとしてしまい400ppmとしか表示されなくなります
オートベースライニング自体は
sgp.setIAQBaseline(eCO2_base, TVOC_base);
で止めることができるので風通しが良いところでオートベースライニングさせて最後の値をsetIAQBaseline()に設定してからSPIFFSに書き込んで記録させることにします
void spiffsWriteBaseline(uint16_t eCO2_new, uint16_t TVOC_new) {
// eCO2とTVOCのbaseline値を書き込む
// uint16_tをuint8_tに分割する
File fp = SPIFFS.open("/baseline", FILE_WRITE);
uint8_t baseline[4] = { (eCO2_new & 0xFF00) >> 8, eCO2_new & 0xFF, (TVOC_new & 0xFF00) >> 8, TVOC_new & 0xFF };
fp.write(baseline, 4);
fp.close();
Serial.printf("SPIFFS Wrote %x %x %x %x\n", baseline[0], baseline[1], baseline[2], baseline[3]);
}
void setup() {
int i = 60; // 60秒キャリブレーションする
long last_millis = 0;
Serial.print("Sensor init\n");
while (i > 0) {
if (millis() - last_millis > 1000) {
last_millis = millis();
i--;
if (sgp.IAQmeasure()) {
Serial.printf("%d:", 60 - i);
Serial.print("eCO2 "); Serial.print(sgp.eCO2); Serial.print(" ppm\t");
Serial.print("TVOC "); Serial.print(sgp.TVOC); Serial.println(" ppb");
if (sgp.getIAQBaseline(&eCO2_base, &TVOC_base)) {
// 現在のbaselineを表示
Serial.print("eCO2: 0x");
Serial.print(eCO2_base, HEX);
Serial.print(" TVOC: 0x");
Serial.println(TVOC_base, HEX);
}
if (!calibrationMode) {
// キャリブレーションモードでは途中終了しない(60秒)
if (sgp.TVOC != 0 || sgp.eCO2 != 400) {
break;
}
}
}
}
}
if (calibrationMode) {
// キャリブレーションモードならばbalselineを設定(固定)してSPIFFSに書き込む
sgp.setIAQBaseline(eCO2_base, TVOC_base);
spiffsWriteBaseline(eCO2_base, TVOC_base);
}
}
##ガスセンサに湿度補正を行う##
ガスセンサの湿度補正はexampleではやってませんがせっかくENV HATがあるのでやっておきましょう
↓サンプルからコピペ
uint32_t getAbsoluteHumidity(float temperature, float humidity) {
// approximation formula from Sensirion SGP30 Driver Integration chapter 3.15
const float absoluteHumidity = 216.7f
* ((humidity / 100.0f) * 6.112f
* exp((17.62f * temperature) / (243.12f + temperature))
/ (273.15f + temperature)); // [g/m^3]
const uint32_t absoluteHumidityScaled = static_cast<uint32_t>(1000.0f
* absoluteHumidity); // [mg/m^3]
return absoluteHumidityScaled;
}
float tmp = dht12.readTemperature();
float hum = dht12.readHumidity();
sgp.setHumidity(getAbsoluteHumidity(tmp, hum));
##RTCを利用して稼働時間を表示する##
CoreInkにはRTCが載っているので稼働時間を表示します
最初は時計にしようと思ったのですが、
バッテリーを持たせるため5分間隔とかで再起動したかったので稼働時間を表示することにしました
キャリブレーション時にRTCを00:00:00に設定して測定時に取得しています
// キャリブレーション時にリセット
RTC_TimeTypeDef TimeStruct;
TimeStruct.Hours = 0;
TimeStruct.Minutes = 0;
TimeStruct.Seconds = 0;
M5.rtc.SetTime(&TimeStruct);
RTC_DateTypeDef RTCDate;
RTCDate.Year = 2021;
RTCDate.Month = 1;
RTCDate.Date = 1;
M5.rtc.SetDate(&RTCDate);
// 稼働時間の表示
RTC_TimeTypeDef RTCtime;
RTC_DateTypeDef RTCDate;
char timeStrbuff[20];
M5.rtc.GetTime(&RTCtime);
M5.rtc.GetDate(&RTCDate);
int hour = 0;
hour = RTCtime.Hours + (RTCDate.Date - 1) * 24;
// 100時間を超えていたら2桁に切り捨てる
if (100 <= hour) {
hour %= 100;
}
sprintf(timeStrbuff, "%02d:%02d", hour, RTCtime.Minutes);
sprite.setCursor(0, 0);
sprite.setTextColor(TFT_BLACK, TFT_WHITE);
sprite.setFont(&fonts::Font7);
sprite.setTextSize(1.3);
sprite.print(timeStrbuff);
CoreInkのバッテリーレベルを取得する
間欠測定で長時間動作させるためバッテリーレベルが分かるととても役立ちます
ミクミンPさんのコードを参考に実装します
ImageToCoreInk/sample_image.ino at main · ksasao/ImageToCoreInk · GitHub
https://github.com/ksasao/ImageToCoreInk/blob/main/sample/arduino/sample_image/sample_image.ino#L96
とここでたなかまさゆきさん @tnkmasayuki からご指摘がありまして、
公式ライブラリじゃなかったです、、、
— たなかまさゆき (@tnkmasayuki) December 19, 2020
公式スケッチ例のサンプルがおかしいです
それ元にしてるこれも解放漏れしていますね
callocした変数を最後にfreeした方がいいです
free(adc_chars);
を、追加しました
M5Paperのコードを参考にするとよいとも教えてくれたんですがそちらはまだ見られてないです
すみませんorz
int getBatCapacity() {
// 4.02 = 100%, 3.65 = 0%
const float maxVoltage = 4.02;
const float minVoltage = 3.65;
int cap = (int) (100.0 * (getBatVoltage() - minVoltage)
/ (maxVoltage - minVoltage));
if (cap > 100) {
cap = 100;
}
if (cap < 0) {
cap = 0;
}
return cap;
}
float getBatVoltage() {
analogSetPinAttenuation(35, ADC_11db);
esp_adc_cal_characteristics_t *adc_chars =
(esp_adc_cal_characteristics_t*) calloc(1,
sizeof(esp_adc_cal_characteristics_t));
esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11, ADC_WIDTH_BIT_12,
3600, adc_chars);
uint16_t ADCValue = analogRead(35);
uint32_t BatVolmV = esp_adc_cal_raw_to_voltage(ADCValue, adc_chars);
float BatVol = float(BatVolmV) * 25.1 / 5.1 / 1000;
free(adc_chars);
return BatVol;
}
日本語を表示するためにLovyanGFXを使う
CoreInkはデフォルトでは英字フォントしかないためLovyanGFXを利用します
こちらを参考にしました
M5Stack CoreInkの使い方 その2 日本語フォントや描画拡張 | Lang-ship
https://lang-ship.com/blog/work/m5stack-coreink-2-font/
いろいろ試した結果lgfxJapanGothicP_20に落ち着きましたがメモリの容量と画面構成によって好きなものを使うとよいと思います
#define LGFX_M5STACK_COREINK // M5Stack CoreInk
#include <LovyanGFX.hpp>
Ink_Sprite InkPageSprite(&M5.M5Ink);
static LGFX_Sprite sprite;
void setup() {
M5.begin();
if (InkPageSprite.creatSprite(0, 0, 200, 200, true) != 0) {
Serial.printf("Ink Sprite create faild");
}
// スプライト作成
sprite.setColorDepth(1);
sprite.createPalette();
sprite.createSprite(200, 200);
// 本来はここでガスセンサの初期化をしている
makeSprite();
pushSprite(&InkPageSprite, &sprite);
}
void makeSprite() {
sprite.clear(TFT_WHITE);
sprite.setFont(&fonts::lgfxJapanGothicP_20);
sprite.setTextSize(1);
sprite.setTextColor(TFT_BLACK, TFT_WHITE);
float tmp = dht12.readTemperature();
float hum = dht12.readHumidity();
sprite.setCursor(0, 65);
sprite.printf("気温%2.0f℃ 湿度%2.0f%\n", tmp, hum);
sgp.setHumidity(getAbsoluteHumidity(tmp, hum));
if (sgp.IAQmeasure()) {
sprite.setCursor(0, 105);
sprite.setTextSize(3);
sprite.printf("%4d", sgp.eCO2);
sprite.setTextSize(1);
sprite.print("ppm");
sprite.setCursor(0, 90);
sprite.printf("二酸化炭素濃度\n");
// デバッグにバッテリー情報を表示
// sprite.setCursor(0, 170);
// sprite.printf("%4.1f:%d", getBatVoltage(), getBatCapacity());
if (1000 < sgp.eCO2) {
sprite.setCursor(0, 170);
sprite.setTextColor(TFT_WHITE, TFT_BLACK);
sprite.setTextSize(1.2);
sprite.print("換気してください\n");
sprite.setTextColor(TFT_BLACK, TFT_WHITE);
}
}
RTC_TimeTypeDef RTCtime;
RTC_DateTypeDef RTCDate;
char timeStrbuff[20];
M5.rtc.GetTime(&RTCtime);
M5.rtc.GetDate(&RTCDate);
int hour = 0;
hour = RTCtime.Hours + (RTCDate.Date - 1) * 24;
// 100時間を超えていたら2桁に切り捨てる
if (100 <= hour) {
hour %= 100;
}
sprite.setCursor(0, 0);
sprite.setTextColor(TFT_BLACK, TFT_WHITE);
sprite.setFont(&fonts::Font7);
sprite.setTextSize(1.3);
sprite.print(timeStrbuff);
sprite.setFont(&fonts::lgfxJapanGothicP_20);
sprite.setTextSize(1);
// バッテリーレベルで乾電池マークを描画する
sprite.fillRect(185, 0, 10, 5, 0);
sprite.fillRect(180, 5, 20, 55, 0);
sprite.fillRect(185, 10, 10, 45 * (100 - getBatCapacity()) / 100, 1);
uint16_t TVOC_base, eCO2_base;
if (sgp.getIAQBaseline(&eCO2_base, &TVOC_base)) {
// デバッグにbaselineを表示
// sprite.setCursor(90, 170);
// sprite.printf("%x,%x", eCO2_base, TVOC_base);
} else {
Serial.println("Failed to get baseline readings");
}
if (!spiffsExist("/baseline")) {
Serial.println("/baseline not found");
sprite.setCursor(0, 160);
sprite.setTextColor(TFT_WHITE, TFT_BLACK);
sprite.setTextSize(0.8);
sprite.print("キャリブレーションしてください");
sprite.setTextColor(TFT_BLACK, TFT_WHITE);
}
}
これでこのような画面表示になります
##RTCを使って間欠動作させる##
せっかく電子ペーパーを使っているので電源オフからのRTC再起動させて稼働時間を延ばしましょう
RTCを使うといってもライブラリに用意されているshutdown()を使うだけです
M5.shutdown(60);
引数は秒数、引数なしにすると単純に電源が切れて再起動しません
キャリブレーションしたい時に自動再起動しても困るので
再起動時に上ボタンを入れっぱなしにしておくとキャリブレーションモードに入るようにsetup()内のM5.BtnUP.isPressed()部分に記述しておきます
また、下に入れておくと自動再起動が止まり下ボタンを押す度に再計測するモードになるようにしておきました
void setup() {
M5.begin();
M5.update();
if (M5.BtnUP.isPressed()) {
Serial.println("BtnUP.isPressed()");
calibrationMode = true;
}
if (M5.BtnDOWN.isPressed()) {
Serial.println("BtnDOWN.isPressed()");
// 再起動後下ボタンを押しっぱなしにするとインターバルモードから抜ける
if (sleepMode) {
sleepMode = false;
}
}
Wire.begin(25, 26);
if (sgp.begin(&Wire)) {
uint16_t TVOC_base;
uint16_t eCO2_base;
uint8_t calibrationNum = 0;
if (SPIFFS.begin(true)) {
// キャリブレーションフラグをチェック
if (calibrationMode) {
Serial.println("calibration mode");
calibrationNum = 60;
calibrateSprite();
pushSprite(&InkPageSprite, &sprite);
// 累積稼働時間のリセット
RTC_TimeTypeDef TimeStruct;
TimeStruct.Hours = 0;
TimeStruct.Minutes = 0;
TimeStruct.Seconds = 0;
M5.rtc.SetTime(&TimeStruct);
RTC_DateTypeDef RTCDate;
RTCDate.Year = 2021;
RTCDate.Month = 1;
RTCDate.Date = 1;
M5.rtc.SetDate(&RTCDate);
}
else {
Serial.println("normal mode");
calibrationNum = 30;
calibrationMode = false;
measureSprite();
pushSprite(&InkPageSprite, &sprite);
if (spiffsExist("/baseline")) {
File fp = SPIFFS.open("/baseline", FILE_READ);
uint8_t baseline2[4];
int i = 0;
while (fp.available()) {
baseline2[i] = fp.read();
i++;
}
eCO2_base = baseline2[0] & 0xff;
eCO2_base = eCO2_base << 8;
eCO2_base = eCO2_base | baseline2[1];
TVOC_base = baseline2[2] & 0xff;
TVOC_base = TVOC_base << 8;
TVOC_base = TVOC_base | baseline2[3];
sgp.setIAQBaseline(eCO2_base, TVOC_base);
fp.close();
Serial.print("SPIFFS eCO2: 0x");
Serial.print(eCO2_base, HEX);
Serial.print(" & TVOC: 0x");
Serial.println(TVOC_base, HEX);
}
}
} else {
Serial.println("SPIFFS Mount Failed");
}
int i = calibrationNum;
long last_millis = 0;
Serial.print("Sensor init\n");
while (i > 0) {
if (millis() - last_millis > 1000) {
last_millis = millis();
i--;
if (sgp.IAQmeasure()) {
Serial.printf("%d:", calibrationNum - i);
Serial.print("eCO2 "); Serial.print(sgp.eCO2); Serial.print(" ppm\t");
Serial.print("TVOC "); Serial.print(sgp.TVOC); Serial.println(" ppb");
if (sgp.getIAQBaseline(&eCO2_base, &TVOC_base)) {
// 現在のbaselineを表示
Serial.print("eCO2: 0x");
Serial.print(eCO2_base, HEX);
Serial.print(" TVOC: 0x");
Serial.println(TVOC_base, HEX);
}
if (!calibrationMode) {
// キャリブレーションモードでは途中終了しない(60秒)
if (sgp.TVOC != 0 || sgp.eCO2 != 400) {
break;
}
}
}
}
}
if (calibrationMode) {
// キャリブレーションモードならばbalselineを設定(固定)してSPIFFSに書き込む
sgp.setIAQBaseline(eCO2_base, TVOC_base);
spiffsWriteBaseline(eCO2_base, TVOC_base);
}
Serial.println("done");
} else {
Serial.println("Sensor not found :(");
}
makeSprite();
pushSprite(&InkPageSprite, &sprite);
}
void loop() {
M5.update();
if (M5.BtnUP.wasPressed()) {
Serial.println("BtnUP.wasPressed()");
}
if (M5.BtnDOWN.wasPressed()) {
Serial.println("BtnDOWN.wasPressed()");
if (sleepMode) {
sleepMode = false;
} else {
Serial.printf("Measure/n");
// 抜けた後下ボタンを押すと随時測定できる
makeSprite();
pushSprite(&InkPageSprite, &sprite);
}
}
if (M5.BtnPWR.wasPressed()) {
ButtonTest("Btn PWR Pressed");
M5.shutdown();
}
if (sleepMode) {
M5.shutdown(60);
}
delay(1);
}
メッセージをLINE通知で送信する
換気が必要だと表示するだけでは気づきにくいのでLINE通知を使うことにしました
[ちょっとブレイク]猫にとって超音波センサーって何?―にゃんてっく☆やせないアイツとESP8266【vol4】 | リクナビNEXTジャーナル
https://next.rikunabi.com/journal/20170412_t12_iq/
「猫の食生活」を超音波距離センサーでLINEに通知してみた─にゃんてっく☆やせないアイツ【vol5】 | リクナビNEXTジャーナル
https://next.rikunabi.com/journal/20170719_t12_iq/
こちらの記事を参考に・・・というかほぼコピペしました
アクセストークンを取得してtoken変数に設定します
HTTP POSTするだけという単純な実装ですね
ついでにバッテリーが3.3Vを切ったら充電するように通知を追加しました
if (1000 < sgp.eCO2) {
sendNotify(String(sgp.eCO2) + "ppm 換気してください");
}
if (getBatVoltage() < 3.3) {
sendNotify("充電してください");
M5.shutdown();
}
void sendNotify(String message) {
WiFi.begin();
while (WiFi.status() != WL_CONNECTED) delay(500);
const char* host = "notify-api.line.me";
const char* token = "";
WiFiClientSecure client;
Serial.println("Try");
//LineのAPIサーバに接続
if (!client.connect(host, 443)) {
Serial.println("Connection failed");
return;
}
Serial.println("Connected");
//リクエストを送信
String query = String("message=") + String(message);
String request = String("") +
"POST /api/notify HTTP/1.1\r\n" +
"Host: " + host + "\r\n" +
"Authorization: Bearer " + token + "\r\n" +
"Content-Length: " + String(query.length()) + "\r\n" +
"Content-Type: application/x-www-form-urlencoded\r\n\r\n" +
query + "\r\n";
client.print(request);
//受信終了まで待つ
while (client.connected()) {
String line = client.readStringUntil('\n');
if (line == "\r") {
break;
}
}
String line = client.readStringUntil('\n');
Serial.println(line);
}
まとめ
いろいろとありましたが出来上がるとこんな感じになります
#CoreInk とENV HATとTVOCガスセンサUnitで環境測定器デキター\(^o^)/
— もけ@ムギ㌠ (@coppercele) December 27, 2020
キャリブレーション機能も付けたのでガスセンサをインターバル動作すると測定値がずれていく問題も解決した\(^o^)/#M5Stack pic.twitter.com/lzVz9UOZq8
coppercele/CoreInkEnvMonitor
https://github.com/coppercele/CoreInkEnvMonitor
1分間隔で再起動するようにしてますが、1晩くらいしかバッテリーが持たないので15分間隔にしたところ5日半ほどバッテリーが持ちました
M5.shutdown(60);
の引数を900くらいにしましょう