M5StackでCO2モニターを作って、Teams通知で換気を促すものを作ってみた

#背景
暖かい日の昼過ぎに眠くなってくるので、二酸化炭素濃度のせいではないかと思ったのが作ったきっかけです。
普段それほど広くはない部屋で仕事していることが多いのですが、昼過ぎになんだか眠気を感じることもたまに。人数自体は少ないので、狭く感じるということはないのですが、そういえば、二酸化炭素濃度ってどうなのだろ？と思って作り始めたものです（まぁ、ただの思い付きですね）。

#作ったもの
これです！
M5Stackにガスセンサユニットを取り付けて、ケースに入れてものです。二酸化炭素濃度は、eCO2（二酸化炭素相当値）なので厳密なものではありません。ただ、今回の用途ではこれで十分。
![DSC_0264.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475457/159a9760-d6b6-a87e-a863-e483cf07221b.jpeg)


#材料
・M5Stack
https://www.switch-science.com/catalog/3648/
・TVOC/eCO2 ガスセンサユニット（SGP30）
https://www.switch-science.com/catalog/6619/
・Base15 産業用プロト基板モジュール
https://www.switch-science.com/catalog/6545/


#作り方
1. M5StackのBOTTOMを外す
![DSC_0267.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475457/2569f264-2e7a-6450-15c6-58e59fcd932b.jpeg)

1. ガスセンサユニットをコネクタ部に接続
![DSC_0268.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475457/d277c263-8ba9-0cbb-59d7-45a69698f6cc.jpeg)

1. 産業用プロト基板モジュールの基板を取り外して、BOTTOM側のケースを使う
![DSC_0269.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475457/ed3c345c-12bd-d55d-178f-1959dee8e60f.jpeg)

1. ガスセンサユニットを中に入れて、蓋を閉じる
![DSC_0271.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475457/6cf8cf90-9e05-ff0e-c953-0bc80e9d8a3c.jpeg)
![DSC_0270.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475457/350bd1a8-1f9c-1559-e119-cfc1a7b0470e.jpeg)

1. 組み立ててDINレールを嵌める
![DSC_0272.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475457/8fc073af-0067-2167-9ade-7f3a18ea1779.jpeg)

1. 完成
![DSC_0266.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475457/d6c36d44-1a6e-cc0d-70c1-2676f958912d.jpeg)

# Microsoft Teamsへの通知
二酸化炭素濃度が1000ppmを越えたら、Teamsに通知するようにします。CO2モニターなので、表示を見たら良いのですが、仕事をしていると気が付いたら高くなっていたということが想定されるので、通知機能を実装します。
こんな感じの通知が来るようにしました。他の会話と混ざると分かりにくくなるので、専用チャンネルを作って運用しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/475457/3beb3b78-6a05-323d-f36a-352bb8cc0542.png)

#ソースコード
開発環境は、PlatformIOを使用しています。とりあえず動く、を目指してサクッと作った感じです。

```C:main.cpp
#include <M5Stack.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include "Adafruit_SGP30.h"

#define JST 3600 * 9

void wifiConnect();
void wifiDisconnect();
void notifyTeams(String webhook, String text);

// ★★★★★設定項目★★★★★★★★★★
const char *ssid = "xxxxxxxx";
const char *password = "xxxxxxxx";
// for Notification of Teams.
String webhook = "https://xxxxxxxx.webhook.office.com/xxxxxxxx";
String text = "室内の二酸化炭素濃度が高くなっています。換気をお願いします。";
// ★★★★★★★★★★★★★★★★★★★

Adafruit_SGP30 sgp;
int i = 15;
long last_millis = 0;
// Wifi
bool hasWifi = false;
// date
time_t t;
struct tm *tm;
// Notification
int count_over = 0;
unsigned long tm_start = 0;

void header(const char *string, uint16_t color)
{
  M5.Lcd.fillScreen(color);
  M5.Lcd.setTextSize(1);
  M5.Lcd.setTextColor(TFT_WHITE, TFT_BLACK);
  M5.Lcd.fillRect(0, 0, 320, 30, TFT_BLACK);
  M5.Lcd.setTextDatum(TC_DATUM);
  M5.Lcd.drawString(string, 160, 10, 4);
}

void setup()
{
  M5.begin(true, false, true, true);
  M5.Lcd.setBrightness(64);
  dacWrite(25, 0); //disable the speak noise

  // Init Serial.
  Serial.begin(9600);
  Serial.println("SGP30 test");

  header("CO2 Monitor", TFT_BLACK);
  if (!sgp.begin())
  {
    Serial.println("Sensor not found :(");
    while (1)
      ;
  }

  // WiFi接続
  int retry = 0;
  do
  {
    M5.Lcd.setTextSize(2);
    M5.Lcd.setCursor(0, 60);
    M5.Lcd.println("WIFI CONNECTING...");
    wifiConnect();
    retry++;
    if (retry > 3)
    {
      hasWifi = false;
      M5.Lcd.setTextSize(2);
      M5.Lcd.println("ERROR: NO WIFI.");
      delay(3000);
      break;
    }
  } while (WiFi.status() != WL_CONNECTED);

  if (WiFi.status() == WL_CONNECTED)
  {
    hasWifi = true;
  }

  // NTP同期処理
  if (hasWifi == true)
  {
    // NTP同期
    configTime(JST, 0, "ntp.nict.jp", "ntp.jst.mfeed.ad.jp");

    // 時刻の同期待ち
    do
    {
      t = time(NULL);
      tm = localtime(&t);
      Serial.printf("%04d/%02d/%02d %02d:%02d:%02d\n",
                    tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
                    tm->tm_hour, tm->tm_min, tm->tm_sec);
      delay(100);
      M5.Lcd.setTextSize(2);
      M5.Lcd.println("DATE SETTING...");
    } while (tm->tm_year + 1900 < 2000);
  }

  M5.Lcd.clear();
  header("CO2 Monitor", TFT_BLACK);
  //M5.Lcd.drawString("TVOC:", 50, 40, 4);
  //M5.Lcd.drawString("eCO2:", 50, 80, 4);
  Serial.print("Found SGP30 serial #");
  Serial.print(sgp.serialnumber[0], HEX);
  Serial.print(sgp.serialnumber[1], HEX);
  Serial.println(sgp.serialnumber[2], HEX);
  M5.Lcd.drawString("Initialization...", 140, 120, 4);
}

void loop()
{
  t = time(NULL);
  tm = localtime(&t);

  // Initialize Sensor.
  while (i > 0)
  {
    if (millis() - last_millis > 1000)
    {
      last_millis = millis();
      i--;
      M5.Lcd.fillRect(198, 120, 40, 20, TFT_BLACK);
      M5.Lcd.drawNumber(i, 20, 120, 4);
    }
  }

  M5.Lcd.fillRect(0, 40, 320, 160, TFT_BLACK);

  if (!sgp.IAQmeasure())
  {
    Serial.println("Measurement failed");
    return;
  }
  M5.Lcd.fillRect(100, 40, 220, 90, TFT_BLACK);
  // M5.Lcd.drawNumber(sgp.TVOC, 120, 40, 4);
  // M5.Lcd.drawString("ppb", 200, 40, 4);

  // 色を付けて表示
  M5.Lcd.setTextSize(4);
  uint16_t eco2 = round(sgp.eCO2 / 10) * 10; // 1の位の四捨五入
  if (eco2 > 1000)
  {
    M5.Lcd.setTextColor(RED);
  }
  M5.Lcd.drawNumber(eco2, 160, 80, 4);
  M5.Lcd.setTextColor(WHITE);
  M5.Lcd.setTextSize(1);
  M5.Lcd.drawString("ppm", 280, 200, 4);

  // for Debug.
  Serial.print("TVOC ");
  Serial.print(sgp.TVOC);
  Serial.print(" ppb\t");
  Serial.print("eCO2 ");
  Serial.print(sgp.eCO2);
  Serial.println(" ppm");

  // Teamsへの通知
  // 12回連続で値が越えたら通知
  // 一度通知したら30分は通知しない
  if (eco2 > 1000)
  {
    count_over++;
    if (count_over > 12)
    {
      // 前の通知から30分以上経過していないと通知しない
      if (millis() / (1000 * 60) < 30 && tm_start == 0)
      { // 起動後30分以内の場合
        // Teams通知
        notifyTeams(webhook, text);
        // 時間の測定開始
        tm_start = millis();
      }
      else if ((millis() - tm_start) / (1000 * 60) > 30)
      {
        // Teams通知
        notifyTeams(webhook, text);
        // 時間の測定開始
        tm_start = millis();
      }
    }
  }
  else
  {
    // リセット
    count_over = 0;
  }

  delay(5000);
}

void wifiConnect()
{
  Serial.print("Connecting to " + String(ssid));

  //WiFi接続開始
  WiFi.begin(ssid, password);

  //接続を試みる(10秒)
  for (int i = 0; i < 20; i++)
  {
    if (WiFi.status() == WL_CONNECTED)
    {
      //接続に成功。IPアドレスを表示
      Serial.println();
      Serial.print("Connected! IP address: ");
      Serial.println(WiFi.localIP());
      break;
    }
    else
    {
      Serial.print(".");
      delay(500);
    }
  }

  // WiFiに接続出来ていない場合
  if (WiFi.status() != WL_CONNECTED)
  {
    Serial.println("");
    Serial.println("Failed, Wifi connecting error");
  }
}

void wifiDisconnect()
{
  Serial.println("Disconnecting WiFi...");
  WiFi.disconnect(true); // disconnect & WiFi power off
}

// Teams通知
void notifyTeams(String webhook, String text)
{
  int res;
  HTTPClient https;

  Serial.print("connect url :");
  Serial.println(webhook);

  Serial.print("[HTTPS] begin...\n");
  if (https.begin(webhook))
  { // HTTPS

    Serial.print("[HTTP] POST...\n");
    // start connection and send HTTP header
    String payload = "{'text':'" + text + "'}";
    int httpCode = https.POST(payload);

    // httpCode will be negative on error
    if (httpCode > 0)
    {
      // HTTP header has been send and Server response header has been handled
      Serial.printf("[HTTPS] GET... code: %d\n", httpCode);
      //Serial.println(https.getSize());

      // file found at server
      String payload;
      if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY)
      {
        payload = https.getString();
        Serial.println("HTTP_CODE_OK");
        Serial.println(payload);
      }
      res = 1;
    }
    else
    {
      Serial.printf("[HTTPS] GET... failed, error: %s\n", https.errorToString(httpCode).c_str());
      res = -1;
    }
    https.end();
  }
  else
  {
    Serial.printf("[HTTPS] Unable to connect\n");
    res = -1;
  }
}
```

# まとめ
二酸化炭素モニターは比較的安価なものが既にネットショップなどで出回っていますが、通知が来るタイプが良かったので、サクッと作りました。
あとは、現在はTeamsのチームのチャンネルに投稿されるのですが、テレワークで自宅で仕事をしていても問答無用に通知が来るので、社給スマホのBluetoothを使って在席チェックして、部屋で仕事をしている人にだけメンション付けて投稿するとか、もう少しやってみたいこともまだまだいろいろ。