今回は、玄関のドアを開けたらSlackに通知するマシンです。
前回の洗剤の残量を保存してWeb上で表示するのは良いのですが、Web上にあっても自分から見に行く必要があってあんまり便利じゃないです。。なので、残量が少なかったら外に出るときに「洗剤買えよ」と通知して欲しかったというのが、今回のマシンを作る動機です。
いつものごとく以下の3点を意識しましたが、3番目はだいぶ手を抜きました。。
- 安定して動作する
- 省電力
- メカメカしい見た目にしない
ちなみに、今回は乾電池ではなくMicroUSB給電です。フォトリフレクタを使っているので、どうしても乾電池じゃ無理でした。。
工夫点(皆さんに共有したいノウハウ)
今回に限らず、「センサー値の変化を契機にしたインターネットへのアクション」を安定して行うにはどうすれば良いかという話です。
前々回、前回を見てくれた方にはわかると思いますが、安定して動作させるためには**「①処理が必要なときにだけ起きて、②処理したらちゃんと処理が成功したことを確認して、③次の処理が必要なときまでスリープする」**ことが重要です。
今回で言うと、「起動しっぱなしで赤外線センサ(今回ドアの開閉は赤外線センサを使って判断します)の値を監視し続けて値が変化したらSlackに通知」ではなく、**「赤外線センサの変化によってリセットをかけてSlackに通知後、次に赤外線センサの変化があるまで永久スリープ」**といった方法をとります。
具体的には以下のような処理になります。
- ドアが開いたら
RST
(リセットピン)がLOW
、ドアが閉じてHIGH
になる - リセットがかかったのでESPが起動
- Slackに
HTTP POST
リクエスト - レスポンスを見て成功したら(
200 OK
が確認できたら) 5. に進む、失敗したらRST
をLOW
に → 2. に戻る - 永久スリープ。1. に戻る。
こうすると、結構安定して動作してくれます(実際にはMilkcocoaからのデータ取得のときにも成功・失敗判定をします)。
機能
ドアの開閉を契機に起動。Milkcocoaに保存されたデータを取ってきて、データが一定値より小さかったらSlackに通知して次のドアの開閉があるまで永久スリープ。
必要なもの
見た目的に必要なもの
- Arduinoの箱
だいぶ手抜き感ありますが、とりあえず基板が見えないようにはなっています。
機能的に必要なもの
- ESP-WROOM-02(ESP8266)開発ボードと、ピンソケット
- 2入力AND回路(74HC08)と、14本足丸ピンICソケット
- フォトリフレクタ(RPR-220)とそれに使う抵抗(100Ω、100kΩ)
- シュミットトリガ(74HC14)
- (電解コンデンサ(47μF)、100Ω抵抗)※IO16を使わない場合
最後の電解コンデンサ(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Ω抵抗は必要ないです。
回路図
以下、本来やりたかった方。
以下、現在実際に使っている方(IO16
を使わない方)
システム
とってくるデータ
Milkcocoaに保存されたデータをとってきます。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版を使いましょう。
以下、本来やりたかったプログラム。
#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
に落としてリセットしています。それ以外の部分は変わりません。
#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ヶ月以上)通知が来ています。
安定させるためには、固定させる必要がありますね。というか普通にリードスイッチでも良かったかもです。
問題なく動作することがわかったので、今度こそ「重さが減ってきたら通知」にプログラムを変更して運用して、使用感とかを報告できればなとおもっています。