M5 CoreInkとUnit CO2(SCD40)で環境モニタを作る

M5 CoreInkとUnit CO2を使って環境モニタを作ってみました

以前CoreInkとEnv Unit,TVOC/eCO2 ガスセンサユニットで環境モニタを作ったのですが、
複数のセンサを使っているので構成が複雑だったり、
CO2が推定値だったりで不安定だったので新しいユニットが出た機会に作り直してみました

M5 CoreInkとEnvHat,TVOC/eCO2 ガスセンサユニット(SGP30)で環境モニタを作る - Qiita
https://qiita.com/coppercele/items/75e18dfbb23436f73268

ソースファイルはこちらにあります
https://github.com/coppercele/CoreInkEnvMonitor2

## 使用するデバイス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/a3cb8531-9c03-e9ce-8f17-7adf566a7992.png)

M5Stack CoreInk 開発キット（1.5インチ Einkディスプレイ） - スイッチサイエンス
https://www.switch-science.com/catalog/6735/

M5シリーズで電子ペーパーをディスプレイに使用した比較的小型のモデル
M5Paperという大型のモデルもある
電子ペーパーを使用してるので低消費電力で情報を表示することができる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/56d30e51-dfd4-2b56-2d32-28c88987c207.png)

M5Stack用SCD40搭載CO2ユニット（温湿度センサ付き）
https://www.switch-science.com/products/8496

センシリオン社製の SCD40センサを搭載した、光音響式の二酸化炭素測定ユニットです
光音響式で推定値じゃないCO2濃度を測れるという事で購入してみました

## デバイスを接続する

今回は接続するUnitが1つなのでシンプルにGroveで接続します
CoreInkとUnitにレゴ穴が開いているのでテクニックの1x9とピンで接続しました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/6a4f2e89-6ddd-101d-9d44-6893cc36eb1c.png)

## センサからデータを取得する

センシリオンのライブラリを導入します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/f00b493b-4cb5-21cc-03cd-590953638e6e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/848cc2b9-7f19-dea7-7643-3827c2cacb7c.png)


M5Stackのgithubのサンプルコードを参照します
https://github.com/m5stack/M5Unit-ENV/blob/master/examples/Unit_CO2_M5Core/Unit_CO2_M5Core.ino

基本的にはsetup()の中が終わればscd4x.readMeasurementでデータが取得できます

```cpp
void setup() {
    M5.begin();
    M5.Power.begin();

    uint16_t error;
    char errorMessage[256];

    scd4x.begin(Wire);

    // stop potentially previously started measurement
    error = scd4x.stopPeriodicMeasurement();
    if (error) {
        Serial.print("Error trying to execute stopPeriodicMeasurement(): ");
        errorToString(error, errorMessage, 256);
        Serial.println(errorMessage);
    }

    // Start Measurement
    error = scd4x.startPeriodicMeasurement();
    if (error) {
        Serial.print("Error trying to execute startPeriodicMeasurement(): ");
        errorToString(error, errorMessage, 256);
        Serial.println(errorMessage);
    }

    Serial.println("Waiting for first measurement... (5 sec)");
}

void loop() {
    uint16_t error;
    char errorMessage[256];

    delay(100);

    // Read Measurement
    uint16_t co2      = 0;
    float temperature = 0.0f;
    float humidity    = 0.0f;
    bool isDataReady  = false;
    error             = scd4x.getDataReadyFlag(isDataReady);
    if (error) {
        M5.Lcd.print("Error trying to execute readMeasurement(): ");
        errorToString(error, errorMessage, 256);
        Serial.println(errorMessage);
        return;
    }
    if (!isDataReady) {
        return;
    }
    error = scd4x.readMeasurement(co2, temperature, humidity);
}
```

## マルチタスクに対応する
前述のコードではloop()内でdelay()していますが、
数分間隔で動かす場合M5.update()が実行されないためボタンなどが利かなくなります

これを防止するためにセンサ読み取り部分を関数にしてマルチタスクで動かします

こちらを参考にさせていただきました
ESP32のFreeRTOS入門 その3 マルチタスク | Lang-ship
https://lang-ship.com/blog/work/esp32-freertos-l03-multitask/


```cpp
void task1(void *pvParameters) {
  while (true) {

    uint16_t error;
    char errorMessage[256];

    // Read Measurement
    bool isDataReady = false;
    error = scd4x.getDataReadyFlag(isDataReady);
    if (error) {
      Serial.print("Error trying to execute readMeasurement(): ");
      errorToString(error, errorMessage, 256);
      Serial.println(errorMessage);
      return;
    }
    if (!isDataReady) {
      return;
    }
    error = scd4x.readMeasurement(data.co2, data.tempeature, data.humidity);
    if (error) {
      Serial.print("Error trying to execute readMeasurement(): ");
      errorToString(error, errorMessage, 256);
      Serial.println(errorMessage);
    }
    else if (data.co2 == 0) {
      Serial.println("Invalid sample detected, skipping.");
    }
    else {
      makeSprite();
      // 5分おきに測定
      delay(5 * 60 * 1000);
    }
  }
}

setup() {

  // loop()内でdelay()を使うとボタンなどが効かなくなるのでマルチタスクで測定する
  xTaskCreateUniversal(task1, "task1", 8192, NULL, 1, NULL, APP_CPU_NUM);
}

```

## 画面表示を作成する
前回作成した環境モニタと同じくLovyanGFXを利用します

M5 CoreInkとEnvHat,TVOC/eCO2 ガスセンサユニット(SGP30)で環境モニタを作る - Qiita
https://qiita.com/coppercele/items/75e18dfbb23436f73268

```cpp
#include <M5CoreInk.h>
#include <LovyanGFX.hpp>

Ink_Sprite InkPageSprite(&M5.M5Ink);

static LGFX_Sprite sprite;

static LGFX lcd;


void makeSprite() {
  sprite.clear(TFT_WHITE);
  // フォント設定
  sprite.setFont(&fonts::lgfxJapanGothicP_20);
  sprite.setTextSize(1);
  sprite.setTextColor(TFT_BLACK, TFT_WHITE);
  sprite.setFont(&fonts::lgfxJapanGothicP_20);
  sprite.setTextSize(1);
  sprite.setTextColor(TFT_BLACK, TFT_WHITE);

  // Wifiマーク
  sprite.fillCircle(180, 20, 20, TFT_BLACK);
  sprite.fillCircle(180, 20, 16, TFT_WHITE);
  sprite.fillCircle(180, 20, 12, TFT_BLACK);
  sprite.fillCircle(180, 20, 8, TFT_WHITE);
  sprite.fillCircle(180, 20, 4, TFT_BLACK);
  sprite.fillRect(160, 0, 20, 40, TFT_WHITE);
  sprite.fillRect(160, 20, 40, 20, TFT_WHITE);

  RTC_TimeTypeDef RTCtime;
  RTC_DateTypeDef RTCDate;
  char timeStrbuff[20];

  M5.rtc.GetTime(&RTCtime);
  M5.rtc.GetDate(&RTCDate);

  // 時計表示
  sprintf(timeStrbuff, "%02d:%02d", RTCtime.Hours, RTCtime.Minutes);
  sprite.setCursor(0, 0);
  sprite.setTextColor(TFT_BLACK, TFT_WHITE);
  sprite.setFont(&fonts::Font7);
  sprite.setTextSize(1.3);
  sprite.print(timeStrbuff);

  if (data.isWifiEnable) {
  }
  else {
    // wifiが使えない場合アイコンに斜線が入る
    sprite.drawLine(176, 20, 196, 0, TFT_WHITE);
    sprite.drawLine(177, 20, 197, 0, TFT_WHITE);
    sprite.drawLine(178, 20, 198, 0, TFT_WHITE);
    sprite.drawLine(179, 20, 199, 0, TFT_BLACK);
    sprite.drawLine(180, 20, 200, 0, TFT_BLACK);
    sprite.drawLine(181, 20, 201, 0, TFT_BLACK);
    sprite.drawLine(182, 20, 202, 0, TFT_WHITE);
    sprite.drawLine(183, 20, 203, 0, TFT_WHITE);
    sprite.drawLine(184, 20, 204, 0, TFT_WHITE);
  }
  // センサ測定値表示
  sprite.setFont(&fonts::lgfxJapanGothicP_20);
  sprite.setTextSize(1);
  sprite.setCursor(0, 65);
  sprite.printf("気温%2.0f℃ 湿度%2.0f％\n", data.tempeature, data.humidity);

  sprite.setCursor(0, 105);
  sprite.setTextSize(3);
  sprite.printf("%4d", data.co2);
  sprite.setCursor(155, 135);
  sprite.setTextSize(1);
  sprite.print("ppm");
  sprite.setCursor(0, 90);
  sprite.printf("二酸化炭素濃度\n");

  // 乾電池マーク
  sprite.fillRect(185, 22, 10, 10, 0);
  sprite.fillRect(180, 27, 20, 35, 0);
  sprite.fillRect(185, 32, 10, 25 * (100 - getBatCapacity()) / 100, TFT_WHITE);

  // 換気メッセージを表示
  if (1000 < data.co2) {
    sprite.setCursor(0, 170);
    sprite.setTextColor(TFT_WHITE, TFT_BLACK);
    sprite.setTextSize(1.2);
    sprite.print("換気してください\n");
    sprite.setTextColor(TFT_BLACK, TFT_WHITE);
    // sendNotify(String(sgp.eCO2) + "ppm 換気してください " +
    //            String(getBatCapacity()) + "％");
  }

  pushSprite(&InkPageSprite, &sprite);
}
void pushSprite(Ink_Sprite *coreinkSprite, LGFX_Sprite *lgfxSprite) {
  coreinkSprite->clear();
  for (int y = 0; y < 200; y++) {
    for (int x = 0; x < 200; x++) {
      uint16_t c = lgfxSprite->readPixel(x, y);
      if (c == 0x0000) {
        coreinkSprite->drawPix(x, y, 0);
      }
    }
  }
  coreinkSprite->pushSprite();
}

void setup() {
  M5.begin(true, true, false);
  M5.update();
  lcd.init();
  if (InkPageSprite.creatSprite(0, 0, 200, 200, true) != 0) {
    Serial.printf("Ink Sprite create faild");
  }
  // スプライト作成
  sprite.setColorDepth(1);
  sprite.createPalette();
  sprite.createSprite(200, 200);

  sprite.clear(TFT_WHITE);
  sprite.setFont(&fonts::lgfxJapanGothicP_20);
  sprite.setTextSize(1);
  sprite.setTextColor(TFT_BLACK, TFT_WHITE);
  makeSprite();
}
```
これで写真のような表示になります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/6a4f2e89-6ddd-101d-9d44-6893cc36eb1c.png)

## キャリブレーションを実装する

SDC4xにはAutomaticSelfCalibration(ASC)機能があってデフォルトオンなのですが、
収束するのに1週間かかるみたいなので手動キャリブレーションを実装します

本体右のホイールボタン？を押し込みながら電源ON/リセットするとキャリブレーションモードに入ります

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/ff1de4e9-3f52-1792-8b67-a598fc65b99e.png)


検索するとマルツさんの記事がヒットしたのでデータシートと合わせて読みながら実装します
ほぼマルツさんの記事をコピペしました

SCD4xデータシート
https://cdn.sparkfun.com/assets/d/4/9/a/d/Sensirion_CO2_Sensors_SCD4x_Datasheet.pdf

Groveで直結！新定番センサ SCD41で作るCO2＆温湿度計【すぐに動く！M5Stack用サンプル・プログラム公開】 | マルツオンライン
https://www.marutsu.co.jp/pc/static/large_order/CO2_SCD41_20220308

```cpp
setup() {
  M5.begin(true, true, false);
  M5.update();
  scd4x.begin(Wire);
  // ホイールボタン押しっぱなしで電源ON/再起動するとキャリブレーションに入る
  if (M5.BtnMID.isPressed()) {
    // calibration start
    uint16_t correction_;
    uint16_t FRC = 400; // FRCのターゲット値
    Serial.println("calibration start");
    sprite.setCursor(0, 0);
    sprite.setTextSize(1);
    sprite.printf(
        "キャリブレーション中です\n空気の綺麗なところに3分間放置してください");
    pushSprite(&InkPageSprite, &sprite);
    scd4x.stopPeriodicMeasurement(); // 定期測定モードを停止
    delay(500);
    scd4x.performFactoryReset();      // 設定の初期化
    scd4x.startPeriodicMeasurement(); // 定期測定モードを開始
    delay(3 * 60 * 1000);             // 3分間通常動作させる
    scd4x.stopPeriodicMeasurement();  // 定期測定モードを停止
    delay(500);
    scd4x.performForcedRecalibration(FRC, correction_); // FRCを実行
    delay(1000);                                        // FRC後1秒待つ

    // 通常モードでの測定開始
    while (scd4x.startPeriodicMeasurement() == false) {
    }
    Serial.println("Completed."); // FRC完了表示

    // M5.Lcd.setCursor(20, 20);
    Serial.printf("FRC. %d\n", correction_); // FRC補正値を表示

    scd4x.stopPeriodicMeasurement(); // 定期測定モードを停止
    uint16_t asc = 1;
    scd4x.setAutomaticSelfCalibration(asc); // ASCの有効化
    scd4x.getAutomaticSelfCalibration(asc);
    Serial.printf("SCD41:ASC: %s\n", asc == 0 ? "OFF" : "ON");
    scd4x.startPeriodicMeasurement(); // 定期測定モードを開始
  }
```

## ネットワークに接続する
ネットワークに接続してNTPで時計合わせをします(表示の更新は5分毎)

ネットでよくあるサンプルほぼそのままです

```cpp
  WiFi.begin();
  int count = 0;
  while (WiFi.status() != WL_CONNECTED) {
    delay(500); // 500ms毎に.を表示
    Serial.print(".");
    count++;
    if (count == 10) {
      // 5秒
      data.isWifiEnable = false;
      Serial.println("Wifi connection failed");
      break;
    }
    data.isWifiEnable = true;
  }
  // NTPで時計合わせをする
  if (data.isWifiEnable) {
    Serial.println("\nConnected");
    Serial.println("ntp configured");

    configTime(9 * 3600L, 0, "ntp.nict.jp", "time.google.com",
               "ntp.jst.mfeed.ad.jp");

    struct tm timeInfo;

    getLocalTime(&timeInfo);

    RTC_TimeTypeDef TimeStruct;

    TimeStruct.Hours = timeInfo.tm_hour;
    TimeStruct.Minutes = timeInfo.tm_min;
    TimeStruct.Seconds = timeInfo.tm_sec;

    M5.Rtc.SetTime(&TimeStruct);
  }
```


## バッテリー電圧を取得する

前回作った環境モニタと同様の方法で電源電圧を取得します
getBatCapacity()の戻り値を使って乾電池マークの減り具合を描画します

```cpp

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;
}

int getBatCapacity() {
  const float minVoltage = 3.3;
  const float maxVoltage = 3.98;
  int cap =
      map(getBatVoltage() * 100, minVoltage * 100, maxVoltage * 100, 0, 100);

  cap = constrain(cap, 0, 100);
  return cap;
}

  // 乾電池マーク
  // ＋極の飛び出し部分
  sprite.fillRect(185, 22, 10, 10, 0);
  // 乾電池本体
  sprite.fillRect(180, 27, 20, 35, 0);
  // 空き容量に応じて白い部分を描画する
  sprite.fillRect(185, 32, 10, 25 * (100 - getBatCapacity()) / 100, TFT_WHITE);

```

## GASでデータをアップロードする
せっかくデータを取っているのでGAS(Google Action Script)でspreadsheetにデータをアップロードします

こちらを参考にしてセットアップしていきます

ESP32で百葉箱IoT - Qiita
https://qiita.com/marlex/items/3e24a2c56a00421a317a

デプロイのやり方が変わっているのでこんな感じでやります
右上の青い「デプロイ▼」を押すとこうなるのでアクセスできるユーザーを「全員」にします
「自分のみ」のままにすると認証画面が開こうとするのでデータが追加されません(少しハマった)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/9be8f6bc-2834-fd2b-6854-c4a15335e696.png)

デプロイができたらEPS32からGETでデータを送信するのですが、

**現在は仕様が変わっているので接続できません**

現在はレスポンスコード302が帰ってきてしまい接続が失敗してしまうので、
通常のHTTPClientSecureではなくHTTPSRedirectライブラリを使用します

こちらを参考にしました

M5Stack(ESP32)のWiFiClientSecureでGoogle App Scriptと連携しようとしたら、HTTPS通信時に302 Moved Temporarilyが出てしまいハマった話し - Qiita
https://qiita.com/kanamekun/items/acd3bc830eafe16a0ef8

まずこちらのgithubからライブラリをダウンロードして取り込みます

GitHub - jbuszkie/HTTPSRedirect: Clone of https://github.com/electronicsguy/ESP8266.git - just the HTTPSRedirect
https://github.com/jbuszkie/HTTPSRedirect

ライブラリのソースに変更が必要でしたが環境によっては必要なかったりするんですかね？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/fd6ac4c4-fb7c-ba82-c779-3a7c39311277.png)

HTTPClientの代わりにHTTPRedirectを使用してGETを投げます

```cpp
#include "HTTPSRedirect.h"

String host = "script.google.com";
HTTPSRedirect *client = nullptr;
void getToGAS() {
  String url = "/macros/s/"; // デプロイ後に表示されるURLに変更する
  int httpsPort = 443;
  client = new HTTPSRedirect(httpsPort);
  client->setInsecure();
  client->setPrintResponseBody(false);
  if (!client->connect(host.c_str(), httpsPort)) {
    Serial.println("connection failed");
  }
  // URLの最後に気温、湿度、二酸化炭素濃度のデータを追加する
  url += "?temperature=" + String(data.temperature) +
         "&humidity=" + String(data.humidity) + "&co2=" + String(data.co2);
  client->GET(url.c_str(), host.c_str());
  String body = client->getResponseBody();
  Serial.println(body);
  delete client;
  client = nullptr;
}
```
受信するGAS側のコードはこうなります
```javascript
function doGet(e) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheets()[0];
  const params = {
    "timestamp": new Date(),
    "temperature": e.parameter.temperature,
    "humidity": e.parameter.humidity,
    "co2": e.parameter.co2
  };
  sheet.appendRow(Object.values(params));
  return ContentService.createTextOutput('success');
}
```
うまくいくとこのようにspreadsheetに追加されていきます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/6a090770-8001-bf08-b23c-3d1d9f3fd2df.png)

グラフにしてみると意外と面白いですね
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/9a7cfc03-7004-b87a-0e56-2066b881a8e9.png)


## GASからLINE Notifyを使いスマホに通知を送る
せっかく5分毎に二酸化炭素濃度を計測しているのでスマホに通知を送ることにしました

ESP32から直接LINE Notifyを叩いてもいいんですが、
変更があったときにPCに繋いで書き込まなくてもいいのでGASから叩くことにしました

LINE Notifyについては以前に書いた記事も参考にしてください
M5 CoreInkとEnvHat,TVOC/eCO2 ガスセンサユニット(SGP30)で環境モニタを作る - Qiita
https://qiita.com/coppercele/items/75e18dfbb23436f73268#%E3%83%A1%E3%83%83%E3%82%BB%E3%83%BC%E3%82%B8%E3%82%92line%E9%80%9A%E7%9F%A5%E3%81%A7%E9%80%81%E4%BF%A1%E3%81%99%E3%82%8B

こちらのサイトを参考にします
【GAS】LINE Notifyで通知を送る方法
https://tetsuooo.net/gas/2739/

GASのエディタに関数を追加します
ほぼコピペですねｗ

```javascript
//LINEにデータを送信する関数
function sendMessage(co2){
  //A, LINE Notifyのトークンを登録
  const token = "";
  const lineNotifyApi = "https://notify-api.line.me/api/notify";
  const message = "\n換気してください:" + co2 + "ppm";

  //B, LINEに送信する設定
  const options =
   {
     "method"  : "post", //POST送信
     "payload" : "message=" + message, //送信するメッセージ
     "headers" : {"Authorization" : "Bearer "+ token}
   };

   //C, FetchメソッドでLINEにメッセージを送信
   UrlFetchApp.fetch(lineNotifyApi, options);
}

function doGet(e) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheets()[0];
  const params = {
    "timestamp": new Date(),
    "temperature": e.parameter.temperature,
    "humidity": e.parameter.humidity,
    "co2": e.parameter.co2
  };
  if (1200 < e.parameter.co2) {
    sendMessage(e.parameter.co2);
  }
  sheet.appendRow(Object.values(params));
  return ContentService.createTextOutput('success');
}
```

doGet()の内部で二酸化炭素濃度が1200ppmより大きければLINE Notifyを叩くようにしました

このような感じで通知が送られてきます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298776/406e0e0e-3af0-fe72-3be5-4cfbc159dfdd.png)

## まとめ
SCD40はCO2、気温、湿度センサがまとめてワンパッケージになってるので
使いやすくて良かったです

ちなみにSCD41にはLow power single shotモードがあるみたいなので、
CoreInkと合わせて最大限に生かせるかもしれないですね
(自分がCO2 Unit買ったときには未発売だった)