ESP8266を使って玄関ドアを開けたらSlackに通知するマシンを作って、実用できるか試す

[前々回](http://qiita.com/kiyopikko/items/fb3bfb0b77191cab0567)、[前回](http://qiita.com/kiyopikko/items/822c7e1b64469c514f7e)に続き実用できるか試すシリーズです。

![genkan1.jpg](https://qiita-image-store.s3.amazonaws.com/0/94526/dda5de95-57ef-dec6-3d45-dee67961275d.jpeg)


今回は、玄関のドアを開けたらSlackに通知するマシンです。

[前回](http://qiita.com/kiyopikko/items/822c7e1b64469c514f7e)の洗剤の残量を保存して[Web上で表示](http://kiyopikko-detergent.netlify.com/)するのは良いのですが、Web上にあっても自分から見に行く必要があってあんまり便利じゃないです。。なので、残量が少なかったら外に出るときに「洗剤買えよ」と通知して欲しかったというのが、今回のマシンを作る動機です。


いつものごとく以下の3点を意識しましたが、3番目はだいぶ手を抜きました。。

- 安定して動作する
- 省電力
- メカメカしい見た目にしない

ちなみに、今回は乾電池ではなくMicroUSB給電です。フォトリフレクタを使っているので、どうしても乾電池じゃ無理でした。。


# 工夫点（皆さんに共有したいノウハウ）

今回に限らず、「センサー値の変化を契機にしたインターネットへのアクション」を安定して行うにはどうすれば良いかという話です。

[前々回](http://qiita.com/kiyopikko/items/fb3bfb0b77191cab0567)、[前回](http://qiita.com/kiyopikko/items/822c7e1b64469c514f7e)を見てくれた方にはわかると思いますが、安定して動作させるためには**「①処理が必要なときにだけ起きて、②処理したらちゃんと処理が成功したことを確認して、③次の処理が必要なときまでスリープする」**ことが重要です。

今回で言うと、「起動しっぱなしで赤外線センサ（今回ドアの開閉は赤外線センサを使って判断します）の値を監視し続けて値が変化したらSlackに通知」ではなく、**「赤外線センサの変化によってリセットをかけてSlackに通知後、次に赤外線センサの変化があるまで永久スリープ」**といった方法をとります。


具体的には以下のような処理になります。

1. ドアが開いたら`RST`（リセットピン）が`LOW`、ドアが閉じて`HIGH`になる
2. リセットがかかったのでESPが起動
3. Slackに`HTTP POST`リクエスト
4. レスポンスを見て成功したら(`200 OK`が確認できたら) 5. に進む、失敗したら`RST`を`LOW`に → 2. に戻る
5. 永久スリープ。1. に戻る。


こうすると、結構安定して動作してくれます（実際にはMilkcocoaからのデータ取得のときにも成功・失敗判定をします）。


# 機能

ドアの開閉を契機に起動。Milkcocoaに保存されたデータを取ってきて、データが一定値より小さかったらSlackに通知して次のドアの開閉があるまで永久スリープ。


# 必要なもの

## 見た目的に必要なもの

- Arduinoの箱

![genkan2.jpg](https://qiita-image-store.s3.amazonaws.com/0/94526/0c24dc37-907d-aced-4ccb-269855e322ee.jpeg)


だいぶ手抜き感ありますが、とりあえず基板が見えないようにはなっています。


## 機能的に必要なもの

- [ESP-WROOM-02(ESP8266)開発ボード](https://www.switch-science.com/catalog/2500/)と、[ピンソケット](http://akizukidenshi.com/catalog/g/gC-05779/)
- [2入力AND回路(74HC08)](http://eleshop.jp/shop/g/gT11459/)と、[14本足丸ピンICソケット](http://eleshop.jp/shop/g/gF91317/)
- [フォトリフレクタ(RPR-220)](http://eleshop.jp/shop/g/g567131/)とそれに使う抵抗（100Ω、100kΩ）
- [シュミットトリガ（74HC14）](http://akizukidenshi.com/catalog/g/gI-08879/)
- （電解コンデンサ(47μF)、100Ω抵抗）※IO16を使わない場合

![genkan3.jpg](https://qiita-image-store.s3.amazonaws.com/0/94526/771dccb2-3019-3fc7-0dcb-1fb3ac8c2c0c.jpeg)

![genkan4.jpg](https://qiita-image-store.s3.amazonaws.com/0/94526/be54ea23-27e0-5c64-902d-3446143ca8a8.jpeg)



最後の電解コンデンサ(47μF)、100Ω抵抗は、私が`ESP.deepSleep(0)`と指定すれば永久スリープが出来ることを知らなかったことによって必要になったものです。

`ESP.deepSleep(0)`で永久スリープが出来ることを知らなかった私は、`RST`と`IO16`を繋げないことで、`IO16`からのWAKE命令（`LOW`）が`RST`に行かないようにして無理矢理永久スリープさせてました。。

それで、通信が失敗したときにプログラムからリセットかける用のpinは別（`IO13`）に用意して、`RST`と繋げてました。

この場合、`IO13`の`pinMode`を`OUTPUT`にしたときに一瞬だけ`LOW`になってリセットがかかってしまっていたので、その対策としてRC（コンデンサ・抵抗）を挟んで解決しました。

なんかややこしいですが、皆さんは普通に`IO16`を`RST`に繋いでもらって大丈夫で、電解コンデンサ(47μF)、100Ω抵抗は必要ないです。


# 回路図

以下、本来やりたかった方。

![玄関_haisen.png](https://qiita-image-store.s3.amazonaws.com/0/94526/e46096fd-c6df-81da-9738-f95a963d9f17.png)


以下、現在実際に使っている方（`IO16`を使わない方）

![玄関_haisen_old.png](https://qiita-image-store.s3.amazonaws.com/0/94526/f06ede1e-dfed-b7c6-9521-9bb9bf18c18a.png)



# システム

## とってくるデータ

[Milkcocoa](https://mlkcca.com/)に保存されたデータをとってきます。[Milkcocoa ESP8266 SDK](https://github.com/milk-cocoa/Milkcocoa_ESP8266_SDK)にデータ取得用のAPIがまだないので、`HTTP GET`で取ってきます（詳しくは後述のプログラムを参照）。

## 通知先

Slackに通知します。ちなみに、MilkcocoaもSlackもHTTPSなので、**プログラムではHTTPS用のライブラリを使用する必要があるので注意です**。


## ソフトウェア

起動をしたら、Milkcocoaに`HTTP GET`して値を取得、取得できていなかったらリセットピンを`LOW`に。

取得した値が一定値より小さかったら、SlackのWebhook URLに`HTTP POST`する、失敗していたらリセットピンを`LOW`に。

Slackへの`HTTP POST`の成功が確認できたら、永久スリープ。


## ハードウェア

フォトリフレクタの出力（フォトトランジスタのコレクタ-エミッタ間電圧）は、ものとの距離が近ければ`LOW`になるので、シュミットトリガをはさんで反転させます。なお、フォトリフレクタの感度を良くしたかったので、抵抗は100kΩと大きめです。

フォトリフレクタの出力（反転済み）と、`IO16`の出力を`AND`回路でつなげて`RST`に出力してあげることで、どちらかが`LOW`になったらリセットがかかるようになります。

※流してる電流がICの定格のギリギリなのでちゃんとやる場合はトランジスタをお使い下さい。



# プログラム

httpsを使いたいので、`WiFiClient.h`ではなく、`WiFiClientSecure.h`を使います。stable版で動かなかったら[staging版](https://github.com/esp8266/Arduino#staging-version-)を使いましょう。


以下、本来やりたかったプログラム。

```cpp
#include <ESP8266WiFi.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>

const char* ssid     = "SSID";
const char* password = "PASS";

const char* host = "あなたのmilkcocoaのapp_id.mlkcca.com";
const char* slackhost = "hooks.slack.com";

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

  Serial.println("##################");
  Serial.println("Start of setup");

  Serial.println();
  Serial.println("WiFi========");
  Serial.print("Connecting to ");
  Serial.println(ssid);

  // WiFi
  WiFi.begin(ssid, password);

  int wifiCounter = 0;

  while (WiFi.status() != WL_CONNECTED) {
    delay(450);
    Serial.print(".");
    wifiCounter++;
    if(wifiCounter > 25) {
      // 一定時間経ってもWiFiが繋がらなかったら再起動
      Serial.println("\nReboot");
      delay(1000);
      ESP.deepSleep(2 * 1000 * 1000);
      delay(1000);
    }
    delay(50);
  }

  Serial.println("");
  Serial.println("WiFi connected");  
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
  delay(1000);

  Serial.println("\nTCP Milkcocoa==========");
  Serial.print("Connecting to ");
  Serial.println(host);

  // connect
  WiFiClientSecure client;
  const int httpsPort = 443;
  if (!client.connect(host, httpsPort)) {
    Serial.println("Connection failed");
    // 接続失敗したら再起動
    Serial.println("\nReboot");
    delay(1000);
    ESP.deepSleep(2 * 1000 * 1000);
    delay(1000);
    return;
  }

  // milkcocoaから値をとってくるURLはこんな感じ
  char* url = "/api?appid=あなたのmilkcocoa_app_id&api=query&limit=1&sort=DESC&path=データストア名";

  Serial.println("Connection success");

  delay(200);

  Serial.println("\nHTTP Milkcocoa==========");
  Serial.print("Requesting URL: ");
  Serial.println(url);

  // GET
  client.print(String("GET ") + url + " HTTP/1.1\r\n" +
               "Host: " + host + "\r\n" + 
               "User-Agent: BuildFailureDetectorESP8266\r\n" +
               "Connection: close\r\n\r\n");
  delay(10);
  Serial.println("Request was sent");

  String line;

  while (client.connected()) {
    line = client.readString();
    if (line == "\r") {
      Serial.println("Headers received");
      break;
    }
  }
  line = client.readString();
  int lineLength = line.length();
  boolean write_flag = false;
  String data = "";

  const char *lineChar = line.c_str();

  for(int i = 0; i < lineLength; i++) {
    if(write_flag){
      data = data + lineChar[i];
      if(lineChar[i] == '}'){
        write_flag = false;
      }
    }
    // "value" の中だけを data[] につっこむ
    if(lineChar[i-7] == 'v' && lineChar[i-6] == 'a' && lineChar[i-5] == 'l' && lineChar[i-4] == 'u' && lineChar[i-3] == 'e' && lineChar[i-2] == '"' && lineChar[i-1] == ':' && lineChar[i] == '"'){
      write_flag = true;
    }
  }

  // エスケープ文字を削除
  data.replace("\\","");

  Serial.println("***Responce data***");
  Serial.println(data);

  const char *json = data.c_str();
  StaticJsonBuffer<1000> jsonBuffer;
  JsonObject& root = jsonBuffer.parseObject(json);
  // 今回は、milkcocoaに保存するときの値のkey名は"v"だった
  float weight = root["v"];
  Serial.print("Get value: ");
  Serial.println(weight);
  Serial.println("***************");

  if(weight < 1){
    // データが取得できていなかったら再起動
    Serial.println("\nReboot");
    delay(1000);
    ESP.deepSleep(2 * 1000 * 1000);
    delay(1000);
    return;
  }
  
  delay(1000);
  client.stop();
  Serial.println(F("Connection closing..."));

  delay(1000);

// 値が200より小さかったらSlackに通知
if(weight < 200){

  char inChar;
  char outBuf[128];

  Serial.println("\nTCP Slack===============");
  Serial.print("Connecting to ");
  Serial.println(slackhost);

  if (!client.connect(slackhost, httpsPort)) {
    Serial.println("Connection failed");
    // 接続失敗の場合は再起動
    Serial.println("\nReboot");
    delay(1000);
    ESP.deepSleep(2 * 1000 * 1000);
    delay(1000);
    return;
  }

  Serial.println(F("Connection success"));

  Serial.println("\nHTTP Slack===============");

  String payloadString =  "payload={\"text\":\"The weigth is "+ String(weight) +".\"}";  
  const char *payload = payloadString.c_str();

  // SlackのIncoming Web Hook URLを入力
  url = "/services/XXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXX";

  Serial.print("Requesting URL: ");
  Serial.println(url);

  Serial.print("Payload: ");
  Serial.println(payloadString);

  // ヘッダーを送信
  sprintf(outBuf,"POST %s HTTP/1.1",url);
  client.println(outBuf);
  sprintf(outBuf,"Host: %s",slackhost);
  client.println(outBuf);
  sprintf(outBuf,"User-Agent: BuildFailureDetectorESP8266");
  client.println(outBuf);
  client.println(F("Connection: close\r\nContent-Type: application/x-www-form-urlencoded"));
  sprintf(outBuf,"Content-Length: %u\r\n",strlen(payload));
  client.println(outBuf);

  // bodyを送信
  client.print(payload);

  Serial.println(F("Request was sent"));

  // リクエストを受け取る前に5秒以上は待った方がいいらしい
  delay(7000);

  int connectLoop = 0;
  String stringResponse = "";

  while (client.connected()) {
    stringResponse = client.readString();
    if (line == "\r") {
      Serial.println("Headers received");
      break;
    }
  }

  stringResponse = client.readString();

  Serial.println(F("***Response***"));
  Serial.println(stringResponse);
  Serial.println("***");
  Serial.println(F("Disconnecting."));
  client.stop();

  if(stringResponse.indexOf("200 OK") == -1){
    // 成功していなかったら再起動
    Serial.println("\nReboot");
    delay(1000);
    ESP.deepSleep(2 * 1000 * 1000);
    delay(1000);
    return;
  }
} // end if(weight < 200)
  Serial.println("End of setup");
  Serial.println("##################");
}

void loop() {
  Serial.println("\nDEEP SLEEP START: ");
  delay(1000);
  ESP.deepSleep(0);
  delay(1000);
  Serial.println("DEEP SLeeping...");
}
```

以下、実際に使ってるプログラム（`IO16`を使わない場合）です。`ESP.deepSleep(2 * 1000 * 1000)`の代わりに、13番ピンを`LOW`に落としてリセットしています。それ以外の部分は変わりません。

```cpp
#include <ESP8266WiFi.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>

const char* ssid     = "SSID";
const char* password = "PASS";

const char* host = "あなたのmilkcocoaのapp_id.mlkcca.com";
const char* slackhost = "hooks.slack.com";

void setup() {
  pinMode(13, OUTPUT);
  digitalWrite(13, HIGH);
  Serial.begin(115200);
  delay(10);

  Serial.println("##################");
  Serial.println("Start of setup");

  Serial.println();
  Serial.println("WiFi========");
  Serial.print("Connecting to ");
  Serial.println(ssid);

  // WiFi
  WiFi.begin(ssid, password);

  int wifiCounter = 0;

  while (WiFi.status() != WL_CONNECTED) {
    delay(450);
    Serial.print(".");
    wifiCounter++;
    if(wifiCounter > 25) {
      // 一定時間経ってもWiFiが繋がらなかったら再起動
      Serial.println("HARDWARE RESET by 13-pin");
      digitalWrite(13, LOW);
      delay(10000);
    }
    delay(50);
  }

  Serial.println("");
  Serial.println("WiFi connected");  
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
  delay(1000);

  Serial.println("\nTCP Milkcocoa==========");
  Serial.print("Connecting to ");
  Serial.println(host);

  // connect
  WiFiClientSecure client;
  const int httpsPort = 443;
  if (!client.connect(host, httpsPort)) {
    Serial.println("Connection failed");
    // 接続失敗したら再起動
     Serial.println("HARDWARE RESET by 13-pin");
     digitalWrite(13, LOW);
     delay(10000);
    return;
  }

  // milkcocoaから値をとってくるURLはこんな感じ
  char* url = "/api?appid=あなたのmilkcocoa_app_id&api=query&limit=1&sort=DESC&path=データストア名";

  Serial.println("Connection success");

  delay(200);

  Serial.println("\nHTTP Milkcocoa==========");
  Serial.print("Requesting URL: ");
  Serial.println(url);

  // GET
  client.print(String("GET ") + url + " HTTP/1.1\r\n" +
               "Host: " + host + "\r\n" + 
               "User-Agent: BuildFailureDetectorESP8266\r\n" +
               "Connection: close\r\n\r\n");
  delay(10);
  Serial.println("Request was sent");

  String line;

  while (client.connected()) {
    line = client.readString();
    if (line == "\r") {
      Serial.println("Headers received");
      break;
    }
  }
  line = client.readString();
  int lineLength = line.length();
  boolean write_flag = false;
  String data = "";

  const char *lineChar = line.c_str();

  for(int i = 0; i < lineLength; i++) {
    if(write_flag){
      data = data + lineChar[i];
      if(lineChar[i] == '}'){
        write_flag = false;
      }
    }
    // "value" の中だけを data[] につっこむ
    if(lineChar[i-7] == 'v' && lineChar[i-6] == 'a' && lineChar[i-5] == 'l' && lineChar[i-4] == 'u' && lineChar[i-3] == 'e' && lineChar[i-2] == '"' && lineChar[i-1] == ':' && lineChar[i] == '"'){
      write_flag = true;
    }
  }

  // エスケープ文字を削除
  data.replace("\\","");

  Serial.println("***Responce data***");
  Serial.println(data);

  const char *json = data.c_str();
  StaticJsonBuffer<1000> jsonBuffer;
  JsonObject& root = jsonBuffer.parseObject(json);
  // 今回は、milkcocoaに保存するときの値のkey名は"v"だった
  float weight = root["v"];
  Serial.print("Get value: ");
  Serial.println(weight);
  Serial.println("***************");

  if(weight < 1){
    // データが取得できていなかったら再起動
     Serial.println("HARDWARE RESET by 13-pin");
     digitalWrite(13, LOW);
     delay(10000);
    return;
  }
  
  delay(1000);
  client.stop();
  Serial.println(F("Connection closing..."));

  delay(1000);

// 値が200より小さかったらSlackに通知
if(weight < 200){

  char inChar;
  char outBuf[128];

  Serial.println("\nTCP Slack===============");
  Serial.print("Connecting to ");
  Serial.println(slackhost);

  if (!client.connect(slackhost, httpsPort)) {
    Serial.println("Connection failed");
    // 接続失敗の場合は再起動
     Serial.println("HARDWARE RESET by 13-pin");
     digitalWrite(13, LOW);
     delay(10000);
    return;
  }

  Serial.println(F("Connection success"));

  Serial.println("\nHTTP Slack===============");

  String payloadString =  "payload={\"text\":\"The weigth is "+ String(weight) +".\"}";  
  const char *payload = payloadString.c_str();

  // SlackのIncoming Web Hook URLを入力
  url = "/services/XXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXX";

  Serial.print("Requesting URL: ");
  Serial.println(url);

  Serial.print("Payload: ");
  Serial.println(payloadString);

  // ヘッダーを送信
  sprintf(outBuf,"POST %s HTTP/1.1",url);
  client.println(outBuf);
  sprintf(outBuf,"Host: %s",slackhost);
  client.println(outBuf);
  sprintf(outBuf,"User-Agent: BuildFailureDetectorESP8266");
  client.println(outBuf);
  client.println(F("Connection: close\r\nContent-Type: application/x-www-form-urlencoded"));
  sprintf(outBuf,"Content-Length: %u\r\n",strlen(payload));
  client.println(outBuf);

  // bodyを送信
  client.print(payload);

  Serial.println(F("Request was sent"));

  // リクエストを受け取る前に5秒以上は待った方がいいらしい
  delay(7000);

  int connectLoop = 0;
  String stringResponse = "";

  while (client.connected()) {
    stringResponse = client.readString();
    if (line == "\r") {
      Serial.println("Headers received");
      break;
    }
  }

  stringResponse = client.readString();

  Serial.println(F("***Response***"));
  Serial.println(stringResponse);
  Serial.println("***");
  Serial.println(F("Disconnecting."));
  client.stop();

  if(stringResponse.indexOf("200 OK") == -1){
    // 成功していなかったら再起動
     Serial.println("HARDWARE RESET by 13-pin");
     digitalWrite(13, LOW);
     delay(10000);
    return;
  }
} // end if(weight < 200)
  Serial.println("End of setup");
  Serial.println("##################");
}

void loop() {
  // IO16とRSTが繋がってないので、実質永久スリープ（4200s経ったら消費電力がμAレベルからmAレベルになっちゃうけど）
  Serial.println("\nDEEP SLEEP START: 4200s");
  delay(1000);
  ESP.deepSleep(4200000000);
  delay(1000);
  Serial.println("DEEP SLeeping...");
}
```


# 評価

## 安定して動作するか？

冒頭でさんざん触れたように、成功したときのみプログラムを実行するようにしています。

- WiFiに接続できずに一定時間経ったら再起動
- Milkcocoaへのコネクションが確立できなかったら再起動
- データを取得できていなかったら再起動
- Slackへのコネクションが確立できなかったら再起動
- Slackへの投稿が成功していなかったら再起動

通信の障害で失敗する分には再起動してくれるので安心です。


## 消費電力は？

起きて役割が終わったら眠るので、ESPの消費電力は最低限かなと思います。冒頭でも触れたように、フォトリフレクタの赤外線LEDはつきっぱなしなので乾電池駆動までは無理でした。

ちなみに、今回`RST`と`IO16`を繋げずに無理矢理永久スリープにしましたが、4200秒経って起きようとしているとき（`IO16`から`WAKE`命令が出ているとき）の消費電流はどうだろうとテスターで簡易的に測ってみたところ、`10mA`くらいでした。

消費電力の観点からも、`RST`と`IO16`を繋げて`ESP.deepSleep(0)`で永久スリープしたほうが良いです。


## 見た目

冒頭の写真を見ればわかりますが、思いの外ドアの溝が深くて赤外線の反射が不十分だったので、ドア側に箱をくっつける必要がありました。だいぶ不格好ですね。。

もうちょっと遠くまで測れるセンサを使うべきでした。




# 終わりに

`ESP.deepSleep(0)`に気づけなかったのは悔しいですが、ものはちゃんと出来たので良しとします（実際普通の家電に比べたら大した消費電力じゃないですからね）。RCを挟んで解決する、なんていう自分のちょっとした成長が感じられて良かったといえば良かったです。

1ヶ月くらい問題なく使えるかどうか試して、不具合が出なかったか、1ヶ月後、この記事に追記するかたちで報告しようかと思います。

---

**2016-03-26 追記：1ヶ月半使ってみました。ほぼ問題なく動作しました。**

記事内では、「残量が少ないときのみ通知」と書いていますが、今回は動作確認のため、とりあえず重さが何であれドアが開いたらSlackに通知するようにしていました。なので、問題なく動作した＝ドアを開閉したら必ず通知が来ていたという状況です。

ただ、はじめて2日目とかにちょっと電源コードに触れたりしたせいか、フォトリフレクタの向いている方向がいつの間にかずれていていて、ドアの開閉を検知できないときがありました。

位置を戻してからは、今まで問題なく（1ヶ月以上）通知が来ています。

安定させるためには、固定させる必要がありますね。というか普通にリードスイッチでも良かったかもです。

問題なく動作することがわかったので、今度こそ「重さが減ってきたら通知」にプログラムを変更して運用して、使用感とかを報告できればなとおもっています。