ハロ改(空気質モニタリング装備型)をつくる


今回は前に作った『呼ぶと応えるハロ』の改修型、『ハロ改(空気質モニタリング装備型)』のご紹介です。

開発経緯

以前に長男のリクエストに応える形で作成した『呼ぶと応えるハロ』ですが、シンプルな玩具としてあれはあれでありかなと思う一方、もう少し実用性を上げて、普通に役立つものにできないかなと考えました。そもそもハロは人のパートナー的なロボットなので、例えばAmazon AlexaやGoogle Homeみたいなアシスタントになってくれれば最高です。Raspberry Piとかを駆使すれば近しいものを作れる可能性はありますが、さすがにちょっと時間がかかりそう、というか終わりが見えなさそうな気がしましたので、機能を『空気質のモニタリング』に絞る形で、なるべく手早く、普段使いで役立つものを作ってみることにしました。

特徴

主な使い方として、CO2を常時モニタリングして、ざっくりと今の状態を目の色で教えてくれています。

CO2濃度が1,000ppm未満のときは青色。

1,000ppm以上2,000ppm未満の時は黄色。

2,000ppmを超えると、赤色になります。赤色になるときには、ハロが『ハロ、カンキ、カンキ!』と喋って、換気を促してくれます。

なお、目の色が変わるときには全て、ハロが軽く振動するようになっています。目の色が変わるごとに喋られるとちょっと喋りすぎな気がするのと、かといって、目の色の変化だけだと空気の状態変化に気づきにくいので、振動することによって、あまり気にならない程度に能動的に状態を教えてくれるようにしてみました。

また、台の白い部分が明るさを検知していて、部屋が暗いときには、CO2濃度が高くなっても喋らないようにしています。寝ている間はCO2濃度がどんどん上がっていってしまうため、寝かけのときに喋られるとちょっとびっくりしてしまいますので。

そして、白い部分はスイッチも兼ねていて、1秒程度ここに手をかざしてから手を離すと、現在の温度、相対湿度、CO2濃度を読み上げてくれます。

(ショート動画)

現在の部屋の状態を正確に知りたいときに便利です。

また、ハロがセンシングしたデータは1分に1回クラウドにアップロードするようになっていて、自分のスマホからお部屋の状態をいつでも確認できます。

ハードウェア解説

今回は大きく2つ、ハロ部と台座部に分かれています。ハロの中でなければいけないもの以外の部品はすべて台座に押し込んでいます。まずはハロ側の具材です。

まずはもちろん、素体としてのハロです。これがなければ始まらない。

心臓部は前回に引き続きM5StickC Plusです。ハロの中に綺麗ピッタリ収まるので。

ハロの目をインジケータにしたかったので大きめのNeoPixelを探していたところ、アキバLEDピカリ館様で丁度良いものを売っていたので、これを2つ使用することにしました。

先に述べた通り、目のインジケータと音声以外に振動でも情報伝えられると良いかなと思ったので、前作から継続で振動モータを組み込んでいます。この振動モーターのHAT自体は、確か製作者の方が再生産が難しいことをツイッターで仰っておられていましたが、その後M5Stackの正式なHATとして再発売される見込みのようです。

 

続いて台座部に組み込んでいるモジュールですが、その前に。

ハロ本体側のM5StickCPlusとの通信はGroveポートのみになるので、たくさんのモジュールを繋ぎたい場合はI2Cで繋ぐことがほぼ必須になります。ということで、たくさん繋ぐためのハブが必要になります。今回はたくさん繋ぐために6ポート版を使用しましたが、同じサイズの4ポート版もあります。

続いては、ハロを喋らせるためのモジュールです。

この3つで、I2C接続でハロを喋らせることができるようになります。LSIを変えれば声質を変えることも可能です。

続いてセンサ類の紹介です。全てI2C接続できることを条件に選んでいます。

CO2濃度と温度と相対湿度を高精度に計測できるとっても優秀なモジュール…なのですが、いかんせん高いです。CO2を計測できるモジュールは基本的に高価ですが、ここまで頑張らんでも良かった。計測レンジの狭い簡易的なものでよければ、このあたりでも充分でしょう。

夜寝ている間に「換気!」とか言われても困るので、喋る前に部屋の明るさをチェックしてもらうために使用しています。また、音声で温度・相対湿度・CO2濃度を読み上げるためのスイッチとしても機能させています。それ用に物理スイッチを一個別で追加するように何かとラクかな、と思い。

I2C接続で使えるので便利なのですが、いかんせん、センサの設置位置に対してコネクタの向きがイマイチなので、コネクタを外してコードを直接半田付けして使用しました。

人感センサとして使えばさらに賢い制御ができるかも、と思って購入してみました。実際、環境温度と物体温度の差のバラツキをうまく処理すれば人のいる/いないは判定できそうな気はするのですが、正確にやろうとするとちょっと処理が面倒くさそうだったのと、私の環境ではそこまでしなくても明るさセンサで人の在不在は大体わかる(←照度が高い=照明がついている=人がいる)ので、今回は搭載するだけしといて特に使用していません。

 

あとは個人的なこだわりで、ハロ本体のM5StickC Plusへの電源供給はACアダプタから直結じゃなくて台座経由で行えるようにしたかったので、短いUSB-Type C用の延長ケーブルも使用しました。

 

具材としてはこんな感じです。あとはこれらを、ざっくり

こんな感じで接続します。まずは台座側。

3Dプリンタで作成した土台に

こんな感じでモジュールをセット。

照度センサ部だけをクリアフィラメントで印刷したフタを被せて

ハロを載せまして完成です。ちなみに台座部分の3Dプリント、部品3つで20時間ぐらいかかってます。

接続はこんな感じで、USB Type CとGrove用ケーブルをまとめて繋いでます。

ちなみにハロの中身はこんな感じ。メカニカルでイカす。

 

なお、USBでの常時通電が前提ですが、M5stickCPlusには小容量ですがバッテリーが搭載されているので、十数分程度ならUSBケーブルを外して計測しながらの持ち運びも可能です。

ソフトウェア解説

ソースコードは最後に掲載するとして、少しだけポイントをつけ加えます。

WiFi接続関係

基本的に通電しっぱなしでセンサデータをクラウドにアップロードし続けてほしいので、WiFi不通状態が一定時間以上継続したらソフトウェアリセットをかけるようにしています(loop内の最後)。テストしていないけど、今のところ1ヶ月以上、データが上がらないままストップしている、という状態にはなっていないので、この機能が有効に動作しているか、あるいはWiFi環境がすこぶる快調かどちらかです。

また、M5StickC PlusとWiFiルータの相性によっては、M5StickCPlusの電源投入後の最初の接続確立がほぼ確実に失敗する現象が発生するため(←ウチは該当)、最初の接続に失敗したときにリトライを入れる処理も入れています。

温度/湿度/CO2センサ

使い方・ライブラリについては、公式のWikiをご参照ください。大した工夫をしているわけではありませんが、CO2濃度が閾値(2,000ppm)をふらついたときに「換気!換気!」と連呼しないようにするため、

  • CO2濃度がLOWの状態で1,000ppmを超えるとMIDDLE
  • CO2濃度がMIDDLEの状態で2,000ppmを超えるとHIGH
  • CO2濃度がHIGHの状態で1,900ppmを下回るとMIDDLE
  • CO2濃度がMIDDLEの状態で900ppmを下回るとLOW

のような状態管理を入れて、MIDDLE→HIGHの遷移のときだけ発話する、としています。

照度センサ

こちらも使い方・ライブラリについては、公式のWikiをご参照ください。先に何度か述べた通り、ハロが発話する前には明るさをチェックするようにしています。また、手をセンサ上にかざして現在温度・相対湿度・CO2濃度の読み上げスイッチとして機能させるために、照度が閾値以上の状態から0に変化したときにフラグを立て、その後所定時間後(1〜2秒後)に再チェックしてまた照度が閾値以上に戻っていたら発話する、というロジックを入れています。

非接触温度センサ

こちらについては公式のライブラリはないのですが、こちらの方の記事内容に従って、”Adafruit_MLX90614″ライブラリを使用しています。ただ、ハードウェア解説で述べた通り、ここで取得した値については、現状クラウドにアップロードするのみになっています。きちんと使いこなせば人感センサとして使えると思うので、例えば部屋に帰ってきたときに「おかえり!」と言ってもらう、みたいな使い方もできるんじゃないかなと思います。

音声発話

AquesTalk pico LSI用Grove(M5)接続基板の使い方については、こちらのサポートページをご参照ください。

発話用の音声記号列(例えば、『ハロ、換気、換気』なら、『ha’ro kann_ki kann_ki.』)は自分で作成する必要がありますが、音声合成LSIの開発元の公式ページで発話させたい文字列をテキストで打ち込めば、音声記号列に変換して出力してくれます。通常使用なら変換出力された音声記号列で充分だと思いますが、発話内容をカスタマイズしたい場合は、こちらのLSIの仕様書を確認する必要があります。

データの可視化

データの可視化には、データ可視化サービスであるAmbientを使用しています。データを定期的にクラウドにアップロードする処理だけ書いておけばグラフを自動的に作成してくれるので大変便利です。センサデータのアップロード周期が1分に1回程度で、かつ、データの長期保存も不要ということであれば、個人の無料プランの範疇で利用可能なので大変ありがたいです。Arduino向けライブラリも提供してくれているので利用も非常に簡単。このあたりの公式記事を参照頂くと良いと思います。

まとめ

以上、『ハロ改(空気質モニタリング装備型)』のご紹介でした。正直、まだまだやれそうなところはあります。例えば、今回は搭載はしたもののの使用しなかった非接触温度センサをもっと使いこなして人の在不在に対してより細かくリアクションするようにするとか、またこちらも使用しませんでしたが、M5StickC Plusに内蔵のRTCを使えば、朝一番の人感反応検知で「オハヨウ」のような声かけもできると思います。さらに言えば、今回は心臓部にM5StcikC Plusを使用していますが、心臓部も台座側に持ってきてRaspberry Piとかのシングルボードコンピュータを採用すれば、顔認識とかのもっと高度なAI処理も可能になると思います。

というわけで、今後も夢いっぱい、如何様にもカスタマイズできてしまうのですが、キリがないし他に作りたいものもあるので、とりあえずここで区切りとします。ちなみに私は本業ではほぼ在宅勤務なのもあって、換気促しマシーンとして普通に便利に使えています。

 

ソースコード

最後にソースコードの全文を掲載しておきます。

#include "M5StickCPlus.h"

#define PIN_LED 25
#define PIN_VIB 26

#define PWM_CHANNEL 0

#define MEASURE_LONG_INTERVAL_MS  5000
unsigned long measure_long_start_point_ms = 0;

#define MEASURE_SHORT_INTERVAL_MS  1000
unsigned long measure_short_start_point_ms = 0;

#define PRINT_INTERVAL_MS 5000
unsigned long print_start_point_ms = 0;

#define WIFI_CHECK_INTERVAL_MS 60000
unsigned long wifi_check_start_point_ms = 0;
uint8_t reset_counter = 0;

void vibrate(unsigned long vib_ms, unsigned long interval_ms, uint8_t num){
  for(uint8_t i=0;i<num;i++){
    ledcWrite(PWM_CHANNEL, 96);
    delay(vib_ms);
    ledcWrite(PWM_CHANNEL, 0);
    delay(interval_ms);
  }
}

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
#include <WiFi.h>
#include <Ambient.h>
#define UPLOAD_INTERVAL_MS  60000 // 1分に1回
unsigned long upload_start_point_ms = 0;

const char*  ssid        = "xxxxxxxxxxxx";
const char*  password    = "xxxxxxxxxxxx";
const char*  ntpServer   = "ntp.jst.mfeed.ad.jp";
unsigned int ambient_channel_id = 000000;
const char*  ambient_write_key = "xxxxxxxxxxx";

RTC_TimeTypeDef RTC_TimeStruct;
RTC_DateTypeDef RTC_DateStruct;

WiFiClient client;
Ambient ambient;

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

#include "SCD30.h"

#define CO2_THRESHOLD_LOW     1000
#define CO2_THRESHOLD_HIGH    2000
#define CO2_THRESHOLD_OFFSET   100
#define CO2_MEASUREMENT_OFFSET 100 // 外で400ppmになるようにキャリブレーション

float co2 = 0.0;
float temperature = 0.0;
float r_humidity = 0.0;

#define CO2_STATE_LOW    0
#define CO2_STATE_MIDDLE 1
#define CO2_STATE_HIGH   2
uint8_t prev_co2_state = CO2_STATE_LOW;
uint8_t co2_state = CO2_STATE_LOW;

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

#include <Digital_Light_TSL2561.h>
#define LX_THRESHOLD 30

uint16_t lx = 0;
uint16_t prev_lx = 0;

#define READING_TIMER_MS 1500
unsigned long turning_down_point_ms = 0;
boolean is_ready_reading = false;

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

#include <Adafruit_MLX90614.h>

Adafruit_MLX90614 mlx = Adafruit_MLX90614();

double amb_temp = 0.90;
double obj_temp = 0.0;

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

#include <Adafruit_NeoPixel.h>
#define N_COLOR_LED 2

#define LED_COLOR_BLACK  0
#define LED_COLOR_BLUE   1
#define LED_COLOR_YELLOW 2
#define LED_COLOR_RED    3
#define LED_COLOR_GREEN  4

#define LED_LEVEL_MAX 32
#define LED_LEVEL_MIN  0

#define COLOR_INTERVAL_MS 50
unsigned long color_start_point_ms = 0;

Adafruit_NeoPixel pixels = Adafruit_NeoPixel(N_COLOR_LED, PIN_LED, NEO_RGB);
int8_t led_level = LED_LEVEL_MIN;
boolean is_led_level_up = true;

void change_eyes(uint8_t color, int8_t level){
  switch(color){
  case LED_COLOR_BLUE:
    pixels.setPixelColor(0, pixels.Color(0,0,level));
    pixels.setPixelColor(1, pixels.Color(0,0,level));
    break;
  case LED_COLOR_YELLOW:
    pixels.setPixelColor(0, pixels.Color(level,level,0));
    pixels.setPixelColor(1, pixels.Color(level,level,0));
    break;
  case LED_COLOR_RED:
    pixels.setPixelColor(0, pixels.Color(level,0,0));
    pixels.setPixelColor(1, pixels.Color(level,0,0));
    break;
  case LED_COLOR_GREEN:
    pixels.setPixelColor(0, pixels.Color(0,level,0));
    pixels.setPixelColor(1, pixels.Color(0,level,0));
    break;
  default:
    pixels.setPixelColor(0, pixels.Color(0,0,0));
    pixels.setPixelColor(1, pixels.Color(0,0,0));
  }
  pixels.show();
}

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

// 文字列の生成 ... https://www.a-quest.com/demo/
// 文法 ... https://www.a-quest.com/archive/manual/atp3012_datasheet.pdf

#include "BF_AquesTalkPicoWire.h"

AquesTalkPicoWire aqtp;

const char* message_ready   = "ha'ro ju'nnbi kannryo-.\r";
const char* message_kanki   = "ha'ro kann_ki kann_ki.\r";
const char* message_morning = "ha'ro ohayo- ohayo-.\r";
const char* message_noon    = "ha'ro ohirugo'hann ohirugo'hann.\r";
const char* message_sleep   = "ha'ro oyasumi oyasumi.\r";

void speak(const char* message){
  if(TSL2561.readVisibleLux() > LX_THRESHOLD){ // 不在時・就寝中に発話させない
    change_eyes(LED_COLOR_GREEN, LED_LEVEL_MAX);
    aqtp.Send(message);
        while(aqtp.Busy()){
      delay(100); // 発話が終わるまで待つ
    }
    change_eyes(LED_COLOR_BLACK, LED_LEVEL_MAX);
  }
}

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

void setup()
{
  Serial.begin(115200);

  const bool serial_enable(true);
  const bool i2c_enable(true);  // SCL = GPIO33, SDA = GPIO32, frequency = 100kHz
  const bool display_enable(true);
  M5.begin(serial_enable, i2c_enable, !display_enable);

  // ディスプレイは使用しないので明るさをゼロにする
  M5.Axp.ScreenBreath(0);

  // GPIO25を使うときは、GPIO36をフローティング入力にする
  gpio_pulldown_dis(GPIO_NUM_36);
  gpio_pullup_dis(GPIO_NUM_36);
  pinMode(PIN_LED, OUTPUT);

  // 振動モーター用設定(チャネル/周波数/解像度)
  ledcSetup(PWM_CHANNEL, 15000, 8);
  ledcAttachPin(PIN_VIB, PWM_CHANNEL);

  const int      wire_sda(32);       // GPIO32
  const int      wire_scl(33);       // GPIO33
  const uint32_t wire_freq(100000);  // 100kHz
  Wire.begin(wire_sda, wire_scl, wire_freq);

  aqtp.Begin(Wire);

  // set default
  aqtp.WriteSpeed();
  aqtp.WritePause();

  //////////////////////////////////////

  // SCD30の方はinitialize内でWire.beginしないようにライブラリ書き替え済み
  scd30.initialize();
  TSL2561.init();
  mlx.begin();

  // フルカラーLED初期化
  pixels.begin();
  pixels.clear();

  Serial.printf("Connecting to %s ", ssid);
  uint8_t retry_counter = 0;
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    retry_counter++;
    delay(500);
    Serial.print(".");
    if(retry_counter % 10 == 0){
      // 5秒経って接続できなければ、WiFi.beginを再実行
      WiFi.disconnect();
      WiFi.begin(ssid, password);
      Serial.println("");
    }
    if(retry_counter > 38){
      // 3回やり直してもダメなら、本体そのものをリセット
      ESP.restart();
    }
  }
  Serial.println(" Connected.");

  // Set ntp time to local
  configTime(9 * 3600, 0, ntpServer);
  // Get local time
  struct tm timeInfo;
  if(getLocalTime(&timeInfo)) {
    // Set RTC time
    RTC_TimeTypeDef TimeStruct;
    TimeStruct.Hours   = timeInfo.tm_hour;
    TimeStruct.Minutes = timeInfo.tm_min;
    TimeStruct.Seconds = timeInfo.tm_sec;
    M5.Rtc.SetTime(&TimeStruct);
    RTC_DateTypeDef DateStruct;
    DateStruct.WeekDay = timeInfo.tm_wday;
    DateStruct.Month = timeInfo.tm_mon + 1;
    DateStruct.Date = timeInfo.tm_mday;
    DateStruct.Year = timeInfo.tm_year + 1900;
    M5.Rtc.SetData(&DateStruct);
  }

  Serial.print("Ambient setup ... ");
  ambient.begin(ambient_channel_id, ambient_write_key, &client);
  Serial.println("OK.");

  // Ambientにデータを上げるので、WiFiは繋いだままにする
  // WiFi.disconnect(true);
  // WiFi.mode(WIFI_OFF);

  // 準備完了ボイス
  speak(message_ready);
  vibrate(1200, 0, 1);
}

void loop(){
  M5.update();

  unsigned long now_ms = millis();

  ////////////////////// 計測(回数少)処理 //////////////////////

  if(now_ms - measure_long_start_point_ms >= MEASURE_LONG_INTERVAL_MS){
    float result[3] = {0};
    if(scd30.isAvailable()){
      scd30.getCarbonDioxideConcentration(result);
      co2 = result[0] - CO2_MEASUREMENT_OFFSET;
      temperature = result[1];
      r_humidity = result[2];

      prev_co2_state = co2_state;

      if(co2_state == CO2_STATE_LOW){
        if(co2 >= CO2_THRESHOLD_LOW){
          co2_state = CO2_STATE_MIDDLE;
        }else{
          co2_state = CO2_STATE_LOW;
        }
      }else if(co2_state == CO2_STATE_MIDDLE){
        if(co2 >= CO2_THRESHOLD_HIGH){
          co2_state = CO2_STATE_HIGH;
        }else if (co2 < CO2_THRESHOLD_LOW - CO2_THRESHOLD_OFFSET){
          co2_state = CO2_STATE_LOW;
        }else{
          co2_state = CO2_STATE_MIDDLE;
        }
      }else if(co2_state == CO2_STATE_HIGH){
        if(co2 < CO2_THRESHOLD_HIGH - CO2_THRESHOLD_OFFSET){
          co2_state = CO2_STATE_MIDDLE;
        }else{
          co2_state = CO2_STATE_HIGH;
        }
      }
    }

    switch(prev_co2_state){
    case CO2_STATE_LOW:
      if(co2_state == CO2_STATE_MIDDLE){
        vibrate(500, 0, 1);
      }
      break;
    case CO2_STATE_MIDDLE:
      if(co2_state == CO2_STATE_HIGH){
        vibrate(500, 500, 2);
        speak(message_kanki);
      }else if (co2_state == CO2_STATE_LOW){
        vibrate(500, 0, 1);
      }
      break;
    case CO2_STATE_HIGH:
      if(co2_state == CO2_STATE_MIDDLE){
        vibrate(500, 0, 1);
      }
      break;
    default:
      ;
    }

    measure_long_start_point_ms = now_ms;
  }

  ////////////////////// 計測(回数多)処理 //////////////////////

  if(now_ms - measure_short_start_point_ms >= MEASURE_SHORT_INTERVAL_MS){
    prev_lx = lx;

    lx = TSL2561.readVisibleLux();
    amb_temp = mlx.readAmbientTempC();
    obj_temp = mlx.readObjectTempC();

    if(prev_lx >= LX_THRESHOLD && lx == 0){
      is_ready_reading = true;
      turning_down_point_ms = now_ms;
    }

    if(is_ready_reading && ((now_ms - turning_down_point_ms) > READING_TIMER_MS)){
      if(lx >= LX_THRESHOLD){
        char buf_read_temp[64];
        char buf_read_rh[64];
        char buf_read_co2[64];
        char buf_temp[10];
        char buf_rh[10];
        char buf_co2[10];
        dtostrf(temperature, 3, 1, buf_temp);
        dtostrf(r_humidity, 2, 0, buf_rh);
        dtostrf(co2, 4, 0, buf_co2);
        sprintf(buf_read_temp, "ge'nnzaino/o'nndo.<NUMK VAL=%s COUNTER=do>.\r", buf_temp);
        sprintf(buf_read_rh, "_shitsu'do.<NUMK VAL=%s COUNTER=pa-se'nnto>.\r", buf_rh);
        sprintf(buf_read_co2, "shi-o-tsu-no'-do.<NUMK VAL=%s COUNTER=pi-pi-e'mu>.\r", buf_co2);

        speak(buf_read_temp);
        speak(buf_read_rh);
        speak(buf_read_co2);
      }
      is_ready_reading = false;
    }

    measure_short_start_point_ms = now_ms;
  }

  ////////////////////// コンソール表示処理 //////////////////////

  if(now_ms - print_start_point_ms >= PRINT_INTERVAL_MS){
    char buf_time_text[64];
    M5.Rtc.GetTime(&RTC_TimeStruct);
    M5.Rtc.GetData(&RTC_DateStruct);
    sprintf(buf_time_text, "Time ...  %02d:%02d:%02d", RTC_TimeStruct.Hours, RTC_TimeStruct.Minutes, RTC_TimeStruct.Seconds);
    Serial.println(buf_time_text);

    char buf_sensor_text[128];
    char buf_co2[10];
    char buf_temp[10];
    char buf_rh[10];
    char buf_amb_temp[10];
    char buf_obj_temp[10];
    dtostrf(co2, 4, 0, buf_co2);
    dtostrf(temperature, 4, 2, buf_temp);
    dtostrf(r_humidity, 4, 2, buf_rh);
    dtostrf(amb_temp, 4, 2, buf_amb_temp);
    dtostrf(obj_temp, 4, 2, buf_obj_temp);
    sprintf(buf_sensor_text, "CO2: %s ppm, Temp: %s C, RH: %s %%, ILL: %d lx, AmbTemp: %s C, ObjTemp: %s C", buf_co2, buf_temp, buf_rh, lx, buf_amb_temp, buf_obj_temp);
    Serial.println(buf_sensor_text);

    print_start_point_ms = now_ms;
  }  

  ////////////////////// インジケータ(ハロの目)の発行処理 //////////////////////

  if(now_ms - color_start_point_ms >= COLOR_INTERVAL_MS){
    if(is_led_level_up){
      if(led_level < LED_LEVEL_MAX){
        led_level++;
      }else{
        led_level--;
        is_led_level_up = false;
      }
    }else{
      if(LED_LEVEL_MIN < led_level){
        led_level--;
      }else{
        led_level++;
        is_led_level_up = true;
      }
    }

    if(co2 < CO2_THRESHOLD_LOW){
      change_eyes(LED_COLOR_BLUE, led_level);
    }else if(CO2_THRESHOLD_LOW <= co2 && co2 < CO2_THRESHOLD_HIGH){
      change_eyes(LED_COLOR_YELLOW, led_level);
    }else{
      change_eyes(LED_COLOR_RED, led_level);
    }

    color_start_point_ms = now_ms;
  }

  ////////////////////// データアップロード処理 //////////////////////

  if(now_ms - upload_start_point_ms >= UPLOAD_INTERVAL_MS){
    Serial.print("Send data to ambient... ");
    ambient.set(1, co2);
    ambient.set(2, temperature);
    ambient.set(3, r_humidity);
    ambient.set(4, lx);
    ambient.set(5, amb_temp);
    ambient.set(6, obj_temp);
    ambient.set(7, obj_temp - amb_temp);
    if(ambient.send()){
      Serial.println("Success.");
    }else{
      Serial.println("Failed.");
    }

    upload_start_point_ms = now_ms;
  }

  ////////////////////// WiFiの接続が切れたときの処理(再起動) //////////////////////

  if(now_ms - wifi_check_start_point_ms >= UPLOAD_INTERVAL_MS){
    if((WiFi.status() != WL_CONNECTED)){
      reset_counter++;
    }else{
      reset_counter = 0;
    }

    // WiFi未接続が3分継続したらリセット
    if(reset_counter >= 3){
      ESP.restart();
    }

    wifi_check_start_point_ms = now_ms;
  }

  delay(10);
}