M5Stack Core2で低温調理器を作った

#概要
M5Stack Core2を使って低温調理器を作ってみました。

本記事は以前投稿した[「M5Stackで低温調理器を作ってみた」](https://qiita.com/moritakuya/items/1b1795c1db03dbe9ceb0)で作成したモノの改良版です。

#完成物
ヒーター、撹拌用のポンプ、水温センサー、M5Stack Core2、リレーで構成されてます。
![DSC_1945.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504156/ed90cbb7-e56a-1332-909f-ead6207268cd.jpeg)
![DSC_1141_1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504156/61515f40-288b-6777-4b81-cab9a6bca666.jpeg)
機器構成ですが、以前作ったやつのM5StackをM5Stack Core2に交換しただけです。以前の機器構成は[こちら](https://qiita.com/moritakuya/items/1b1795c1db03dbe9ceb0)を参照ください。

#調理してみたもの
低温調理器といえばローストビーフ。温度や時間は[BONIQのサイト](https://boniq.jp/pdf/ttguide.pdf)を参考にさせて頂きました。
![DSC_1144.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504156/1e2aa9dd-e225-e96f-3719-0f0b8803447f.jpeg)
![DSC_1154_1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504156/a2a99065-7e88-226e-c3bb-089a5c2d6c1f.jpeg)

#機能
・水温を設定温度まで加熱し、一定に保つ
・タイマー機能。設定時間が経過したら音でお知らせすると共にLINEで通知する ← <font color="Crimson">**NEW**</font>
・温度設定、時間設定はM5Stack Core2のタッチパネルで可能 ← <font color="Crimson">**NEW**</font>
・温度変化をAmbientでグラフ表示可能 ← <font color="Crimson">**NEW**</font>

#LINE通知
調理完了時に音を出すようにしてますが、音だけだと聞き逃すこともあるかもしれないのでLINEに通知するようにしてみました。
![Screenshot_20211028-165031.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504156/b256da08-1ba9-fba6-d8ad-3ccb3077a2be.png)

LINEが提供しているLINE Notifyというサービスを使用しています。
サービスの登録方法、M5Stackへの実装方法は以下の記事を参考にさせて頂きました。

[M5Stackから位置情報をambientに記録し、LINEに結果を通知してみた。](https://qiita.com/menmenshod/items/03e17059c0df703a5042)

補足として、WiFiClientSecureの使い方がいつの間にやら変わっていたみたいで、次の一文が必要になってます。

```.cpp
WiFiClientSecure client;
client.setInsecure(); // ←この一文が必要
client.connect(host, 443);
```

参照元：[Arduino core for the ESP32 1.0.5でWiFiClinetSecureの使い方が変更されて、HTTPS(TLS)接続前に証明書の検証方法の明示が必要になっていた](https://intellectualcuriosity.hatenablog.com/entry/2021/03/17/180935)

#Ambientによるグラフ表示
計測した水温をAmbientというクラウドサービスに上げてグラフ表示してみました。
![ambient.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504156/df292fe5-27e4-8025-8ee4-9345282b0aab.png)
ローストビーフを作ったときの温度は諸事情により取ってなかったので、このグラフは後日温泉卵を作ったときのAmbientグラフです。
このときは設定温度を65℃にしたのですが、グラフを見ると＋1℃ほど加熱し過ぎてますね。
これは設定温度に達したときにヒーターを止めてもヒーターの余熱により熱し過ぎてしまうためですね。
（オーバーシュートと言うそうです。テレビでやってました。）
これを防ぐには設定温度に達する前に早めにヒーターをOFFしたり、こまめにヒーターのON/OFFを切り替えるなどして温度の上がり方を調整する必要があります。
今回はそこまでやってませんが、市販の低温調理器なんかはそのあたり実装しているんでしょうね。

サービスの登録方法、M5Stackへの実装方法は先ほどと同じ記事を参考にさせて頂きました。

[M5Stackから位置情報をambientに記録し、LINEに結果を通知してみた。](https://qiita.com/menmenshod/items/03e17059c0df703a5042)

とくに補足することもないので説明は割愛させて頂きます。

#ソースコード
開発環境：Arduino IDE 1.8.13
ボードはesp32 1.0.6を使用。この時点の最新版2.0.0ではコンパイルエラーが取れず断念。
画面のチラつき対策として画面描画を約1秒に1回に制限しています。

```cpp:main.ino
#include <OneWire.h>
#include <DS18B20.h>
#include <M5Core2.h>
#include <driver/i2s.h>
#include <AXP192.h>
#include "sound.h"
#include "StopWatch.h"
#include <WiFiClientSecure.h>
#include <ssl_client.h>
#include <HTTPClient.h>
#include "Ambient.h"

// ピンアサイン
#define ONE_WIRE_BUS 27 // 水温センサーピンアサイン
#define RELAY_PIN 32    // Core2ではGroveのピンアサインは32（黄）、33（白）

// スピーカー関連
#define CONFIG_I2S_BCK_PIN 12
#define CONFIG_I2S_LRCK_PIN 0
#define CONFIG_I2S_DATA_PIN 2
#define CONFIG_I2S_DATA_IN_PIN 34
#define Speak_I2S_NUMBER I2S_NUM_0
#define MODE_MIC 0
#define MODE_SPK 1
#define DATA_SIZE 1024

// 画面描画関連
int disp_draw_count = 0;

// 温度センサー関連
OneWire oneWire(ONE_WIRE_BUS);
DS18B20 sensor(&oneWire);

// 温度制御関連
float settei_temp = 60;     // 設定温度
float saiKanetsu_temp = 59.8; // 再加熱温度
bool totatsu = false;       // 設定温度に到達したか
bool relay = false;         // リレーをONにしているかどうか
float temp = 0;             // 現在温度
StopWatch sw(60 * 60);      // ストップウォッチ

// バイブレーション関連
AXP192 power;

// ボタン関連
// Defines the buttons. Colors in format {bg, text, outline}
ButtonColors on_clrs = {BLUE, WHITE, WHITE};
ButtonColors off_clrs = {BLACK, WHITE, WHITE};
Button btn1(0, 170, 76, 40, false ,"Time +", off_clrs, on_clrs);
Button btn2(81, 170, 76, 40, false, "Time -", off_clrs, on_clrs);
Button btn3(162, 170, 76, 40, false, "Temp +", off_clrs, on_clrs);
Button btn4(243, 170, 76, 40, false, "Temp -", off_clrs, on_clrs);

// Wifi関連
const char* ssid = "*******";
const char* password = "*******";

// Ambient関連
WiFiClient client;
Ambient ambient;
unsigned int channelId = *******; // AmbientのチャネルID
const char* writeKey = "*******"; // ライトキー
int sendTimerCount = 0;

void vibration();
bool InitI2SSpeakOrMic(int);
void SpeakInit();
void DingDong();
void eventDisplay(Event&);
void task0(void*);
void line_notify(String);
void RefreshDisplay();

void setup(void)
{
  M5.begin();

  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setCursor(10, 10);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.setTextSize(1);

//  Serial.begin(115200);
  Serial.println(__FILE__);
  Serial.print("DS18B20 Library version: ");
  Serial.println(DS18B20_LIB_VERSION);

  M5.Buttons.addHandler(eventDisplay, E_ALL - E_MOVE);

  sensor.begin();
  pinMode(RELAY_PIN, OUTPUT);// RELAY Pin setting

  SpeakInit();

  M5.Buttons.draw();

  // Wifi接続
  M5.Lcd.printf("Wifi connect.");
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while(WiFi.status() != WL_CONNECTED){
    M5.Lcd.printf(".");
    delay(500);
  }

  // Ambient接続
  ambient.begin(channelId, writeKey, &client); // チャネルIDとライトキーを指定してAmbientの初期化

  // 水温取得スレッド起動
  xTaskCreatePinnedToCore(task0, "TempSensor", 4096, NULL, 1, NULL, 1);

  M5.Lcd.printf("Wake up!");
  delay(1000);
  M5.Lcd.fillRect(0, 0, 320, 169, BLACK);
}

void loop(void)
{
  M5.update();

  // Aボタンでタイマースタート
  if (M5.BtnA.wasPressed()) {
    sw.Start();
    Serial.println("Pushed Start.");

    // 音鳴らす
    vibration();
    DingDong();
  } 
  // Bボタンでタイマーストップ
  else if (M5.BtnB.wasPressed())
  {
    sw.Stop();
    Serial.println("Pushed Stop.");

    vibration();
  }
  // Cボタンでタイマーリセット
  else if (M5.BtnC.wasPressed())
  {
    sw.Reset();
    Serial.println("Pushed Reset.");

    vibration();
    settei_temp -= 1;
    saiKanetsu_temp = settei_temp - 0.2;
  }

  // 温度制御
  bool kanetsu = false;
  if (temp < saiKanetsu_temp)
  {
    totatsu = false;
    kanetsu = true; 
  }
  else if (temp < settei_temp)
  {
    if (totatsu)
    {
      kanetsu = false;   
    }
    else
    {
      kanetsu = true;
    }
  }
  else
  {
    totatsu = true;
    kanetsu = false;  
  }

  // リレー制御して加熱
  if (kanetsu)
  {
    if (!relay)
    {
      digitalWrite(RELAY_PIN, HIGH);
      Serial.println("HIGH");
      relay = true;
    }    
  }
  else
  {
    if (relay)
    {
      digitalWrite(RELAY_PIN, LOW);
      Serial.println("LOW");
      relay = false;
    }
  }

  // タイマー制御
  if (sw.GetIsStart())
  {
    if (sw.GetRemainTime() <= 0)
    {
      // 出来上がり
      sw.Stop();
      // LINEに通知
      line_notify("Dekiagari!!");
      // 音鳴らしてお知らせ
      DingDong();
      DingDong();
      DingDong();
      DingDong();
      DingDong();
    }
  }
  
  // 画面描画
  disp_draw_count++;
  if (disp_draw_count > 100)
  {
    disp_draw_count = 0;
    RefreshDisplay();
  }
  
  delay(10);
}

// バイブレーション
void vibration() {
  power.SetLDOEnable(3, true);  // 3番をtrueにしてバイブレーション開始
  delay(150);                   // バイブレーションの長さ（ms）はお好みで調整
  power.SetLDOEnable(3, false); // 3番をfalseにしてバイブレーション修了
}

// スピーカー初期化
bool InitI2SSpeakOrMic(int mode)
{
    esp_err_t err = ESP_OK;

    i2s_driver_uninstall(Speak_I2S_NUMBER);
    i2s_config_t i2s_config = {
        .mode = (i2s_mode_t)(I2S_MODE_MASTER),
        .sample_rate = 44100,
        .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, // is fixed at 12bit, stereo, MSB
        .channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT,
        .communication_format = I2S_COMM_FORMAT_I2S,
        .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
        .dma_buf_count = 2,
        .dma_buf_len = 128,
    };
    if (mode == MODE_MIC)
    {
        i2s_config.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_PDM);
    }
    else
    {
        i2s_config.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX);
        i2s_config.use_apll = false;
        i2s_config.tx_desc_auto_clear = true;
    }
    err += i2s_driver_install(Speak_I2S_NUMBER, &i2s_config, 0, NULL);
    i2s_pin_config_t tx_pin_config;

    tx_pin_config.bck_io_num = CONFIG_I2S_BCK_PIN;
    tx_pin_config.ws_io_num = CONFIG_I2S_LRCK_PIN;
    tx_pin_config.data_out_num = CONFIG_I2S_DATA_PIN;
    tx_pin_config.data_in_num = CONFIG_I2S_DATA_IN_PIN;
    err += i2s_set_pin(Speak_I2S_NUMBER, &tx_pin_config);
    err += i2s_set_clk(Speak_I2S_NUMBER, 44100, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_MONO);

    return true;
}

// スピーカー初期化
void SpeakInit(void)
{
  M5.Axp.SetSpkEnable(true);
  InitI2SSpeakOrMic(MODE_SPK);
}

// ステキな短い音を鳴らす
void DingDong(void)
{
  size_t bytes_written = 0;
  i2s_write(Speak_I2S_NUMBER, previewR, 120264, &bytes_written, portMAX_DELAY);
}

// ボタン処理
void eventDisplay(Event& e) {
  Serial.printf("%-12s finger%d  %-18s (%3d, %3d) --> (%3d, %3d)   ",
                e.typeName(), e.finger, e.objName(), e.from.x, e.from.y,
                e.to.x, e.to.y);
  Serial.printf("( dir %d deg, dist %d, %d ms )\n", e.direction(),
                e.distance(), e.duration);

  if (strcmp(e.typeName(),"E_TOUCH") == 0)
  {
    if (strcmp(e.objName(), "Time +") == 0)
    {
      sw.SetRemainTime(sw.GetRemainTime() + 1 * 60);
    }
    else if (strcmp(e.objName(), "Time -") == 0)
    {
      sw.SetRemainTime(sw.GetRemainTime() - 1 * 60);
    }
    else if (strcmp(e.objName(), "Temp +") == 0)
    {
      settei_temp += 1;
      saiKanetsu_temp = settei_temp - 0.2;
    }
    else if (strcmp(e.objName(), "Temp -") == 0)
    {
      settei_temp -= 1;
      saiKanetsu_temp = settei_temp - 0.2;
    }
    RefreshDisplay();
  }
}

// 水温取得スレッド
void task0(void* arg) {
  while (1) {
    // 温度取得
    sensor.requestTemperatures();  
    while (!sensor.isConversionComplete());  // wait until sensor is ready
    temp = sensor.getTempC();

    // 5秒間隔でAmbientに温度送信
    sendTimerCount++;
    if (sendTimerCount >= 5)
    {
      sendTimerCount = 0;
      ambient.set(1, String(temp).c_str());
      ambient.set(2, String(settei_temp).c_str());
      ambient.set(3, String(relay ? 1 : 0).c_str());
  
      ambient.send();
    }

    delay(1000);
  }
}

// LINE通知
void line_notify(String msg) {
  const char* host = "notify-api.line.me";
  const char* token = "***********";  // ご自分のLINEトークンに書き換え

  WiFiClientSecure client;
  client.setInsecure();
  Serial.println("Try");
  //LineのAPIサーバに接続
  if (!client.connect(host, 443)) {
    Serial.println("Connection failed");
    return;
  }
  
  Serial.println("Connected");
  //リクエストを送信
  String query = String("message=") + msg;
  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');
    Serial.println(line);
    if (line == "\r") {
      break;
    }
  }

  String line = client.readStringUntil('\n');
  Serial.println(line);
}

// 画面描画
void RefreshDisplay()
{
  M5.Lcd.fillRect(0, 0, 320, 169, BLACK);
  M5.Lcd.setTextColor(WHITE, TFT_BLACK);
  
  // 現在温度表示
  Serial.print("Temp: ");
  Serial.println(temp);

  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(10, 40);
  M5.Lcd.printf("Temp:%.2f",temp);

  // 設定温度表示
  int rhour = 0;
  int rmin = 0;
  int rsec = 0;
  // 残り時間計算
  unsigned long remain = sw.GetRemainTime();
  rhour = (int)(remain / 60 / 60);
  rmin = (int)(remain % (60 * 60) / 60);
  rsec = (int)(remain % 60);

  M5.Lcd.setCursor(10, 90);
  M5.Lcd.printf("%dh%dm%ds", rhour, rmin, rsec);

  M5.Lcd.setTextSize(1);
  M5.Lcd.setCursor(10, 130);
  M5.Lcd.printf("Target temp:%.2f",settei_temp);
  M5.Lcd.setCursor(10, 150);
  M5.Lcd.printf("Re-heat temp:%.2f",saiKanetsu_temp);  
  M5.Lcd.setCursor(40, 230);
  M5.Lcd.printf("v Start");
  M5.Lcd.setCursor(150, 230);
  M5.Lcd.printf("v Stop");
  M5.Lcd.setCursor(250, 230);
  M5.Lcd.printf("v Reset");

  if (relay)
  {
    M5.Lcd.setTextSize(2);
    M5.Lcd.setTextColor(RED);
    M5.Lcd.setCursor(200, 150);
    M5.Lcd.printf("Heat");
  }
}
```

```cpp:StopWatch.h
#ifndef _STOPWATCH_H_
#define _STOPWATCH_H_

class StopWatch{
  public:
    StopWatch(unsigned long);
    void Start();
    void Stop();
    void Reset();
    unsigned long GetRemainTime();
    unsigned long GetSetteiTime();
    void SetRemainTime(unsigned long);
    void SetSetteiTime(unsigned long);
    bool GetIsStart();
    
  private:
    unsigned long targetTime;
    unsigned long setteiTime;

    bool isStart;
    unsigned long remainTime;
};
#endif
```

```cpp:StopWatch.cpp
#include <M5Core2.h>
#include "StopWatch.h"

StopWatch::StopWatch(unsigned long t)
{
  setteiTime = t;
  remainTime = t;
  isStart = false;
}

void StopWatch::Start()
{
  targetTime = remainTime + millis() / 1000;
  isStart = true;
}

void StopWatch::Stop()
{
  remainTime = GetRemainTime();
  isStart = false;
}

void StopWatch::Reset()
{
  isStart = false;
  remainTime = setteiTime;
}

unsigned long StopWatch::GetRemainTime()
{
  if (!isStart)
  {
    return remainTime;
  }
  
  unsigned long currentT = millis() / 1000;
  if (currentT >= targetTime)
  {
    remainTime = 0;
  }
  else
  {
    remainTime = targetTime - currentT;
  }
  return remainTime;
}

unsigned long StopWatch::GetSetteiTime()
{
  return setteiTime;
}

void StopWatch::SetRemainTime(unsigned long t)
{
  remainTime = t;
}

void StopWatch::SetSetteiTime(unsigned long t)
{
  setteiTime = t;
}

bool StopWatch::GetIsStart()
{
  return isStart;
}
```

音データが入っているsound.hは[こちら](https://github.com/m5stack/M5Core2/blob/master/examples/Basics/speak/data.c)から拾ってきました。data.cというファイルをsound.hにリネームして使ってます。この記事で全文載せようとすると上手く表示できなかったので、リンクでご勘弁ください。