安定稼働が絶対条件な、ESP8266で作って実用できるか試すシリーズです。以下、シリーズの過去記事。
今回は、遠隔操作のエアコンリモコンです。
ICS MEDIAさんで遠隔で操作できるエアコンリモコンが紹介されていましたが、これの安定稼働バージョンを作りたいなと思って、作ってみました。
工夫点(皆さんに共有したいノウハウ)
問題
今回の仕組みは本シリーズの過去3本とは決定的に違うことがあり、それが安定稼働の難しさの一番の要因でもあります。
それはズバリ、「アクションの起点がESPではない」ことです。
Umbrella Standと洗剤のやつは「1時間経過したらリセットが走って動作」、Slack通知は「ドアが開閉したらリセットが走って動作」と、両方ESPからアクション(プログラム処理)が始まっています。
なので、「アクションをするときだけ起きて、終わったらスリープ」という方法がとれます。
しかし今回は、アクションの起点が外にあって、それを監視し続ける必要があり、監視するということは常に起動させておかないといけません。
解決法
そこで今回はどうするかというと、番犬を置きます。つまり、「ウォッチドッグタイマを外に作って、処理が止まってしまったらリセットをかける」ようにして安定稼働を実現します。
ウォッチドッグはその名前の通り「番犬」でコンピュータが正常かどうか
を常に監視するためのタイマです。
このタイマの特徴は、タイマがタイムアップするとハードウェアにリセットが
かかるということで。リセットがかかると当然コンピュータは初期状態からの
スタートになり再スタートすることになります。
――ウォッチドッグタイマとは
具体的には、ESPの外側に一定時間(今回は16秒)経ったら立ち上がり信号を出す仕組みをつくって、その立ち上がり信号を出すピンをESPのRESET
ピンに繋げます。
ESPが正常に動いている間は、ESPからその仕組み(ウォッチドッグタイマ)に対してリセットをかけ続けます。ESPで異常が起きてプログラムが止まってしまったら、ウォッチドッグタイマにリセットがかからずに一定時間が経過して立ち上がり信号によりESPのリセットがかかります。
これで、ESPでどんな問題が起きようが、外からリセットをかけてくれるので安定稼働するというわけです(再起動の最中にデータが来た際の対処はのちほど述べます)。
※そもそもESPにはウォッチドッグタイマ機能がついてるんですが、スケッチプログラムからはソフトウェアウォッチドッグしか使えないっぽかったので諦めました。
※※ただ、今改めて調べてみると、こちらのissueで良さげなハックが投稿されていたので、あとで試してみようかと思っています。
機能
それでは、いつも通り仕組みの説明していきます。
機能は、Web上からON/OFFの命令を送ったら、その命令に応じてエアコンに対してON/OFFの赤外線信号を送る、というものです。
今回は、ON/OFFのみで、温度等の調節は出来ません(消し忘れて出かけた、家に帰る前に暖めておきたい場合にしか使わないと思ったので)。
必要なもの
- ESP-WROOM-02(ESP8266)開発ボードと、ピンソケット
- シュミットトリガ(74HC14)と、14本足丸ピンICソケット
- タイマ(LMC555)と8本足丸ピンICソケット
- タイマの時間設定用の部品:抵抗47kΩ×2、セラミックコンデンサ0.1μF、電解コンデンサ10μF
- 非同期式バイナリカウンタ(74HC161)と16本足丸ピンICソケット
- 赤外線LED(と信号確認用の赤外線受信モジュール)
- 赤外線LEDの電流増幅用部品:NPNトランジスタ(2SC1815L)、抵抗1kΩ&10Ω
- スライドスイッチ×2(ウォッチドッグタイマのON/OFF用と、開発中にLEDに大電流を流さない用)
ちなみにガワは、ホームセンターで物色したらポケットティッシュケースがちょうど良かったのでそれを使いました。
回路図・配線
回路図の作成にはこちらの記事を見て良さげだったので、Upverterを使ってみました。回路図の正しい書き方がわかりませんが。。
このバイナリカウンタは0〜15までカウントするカウンタで、15までカウントしたらCO
(↑の画像ではRCO
)がHIGH
になるので、周期を1秒にしたタイマIC555にカウントさせることで、「16秒のウォッチドッグタイマ」を作りました。
書き込みの際は、ウォッチドッグタイマをOFFにしないと書き込み中にリセットがかかるという事態が起こってしまうので、スライドスイッチをはさむことでON/OFFを切り替えられるようにしました。
赤外線LEDはパルス電流を流す場合の定格が1000mA、流し続ける場合は100mAで、今回500mAほど流すようにしているので、開発中にトランジスタのベースに繋いでいるピンがHIGH
になって電流が流れ続けてしまうといけないので、そこもスライドスイッチを挟みました。
ユニバーサル基板上では以下のように実装しました(自分用なのでわからない部分が多いかと思いますがなんとなくイメージを掴んで頂ければ)。
システム
ウォッチドッグへのエサやり(Feeding)
ESP側でMilkcocoaのon
メソッドを使って、データが来るのをSubscribeします。このとき、push
(データの送信と保存を行うAPI)とsend
(データの送信を行うAPI)の両方をSubscribeします。
両方のSubscribeのコールバックで、カウンタのリセット処理、つまりカウンタに繋がっているIOピンをLOW
に落としたあとHIGH
に戻すようにします。
そして、loop()
を2秒おきに実行して、ループごとにcount
をインクリメントして、5の倍数になったら自分自身にデータを送ります(send
を発行します)。データを送ったら、Subscribeのコールバックが呼ばれて、リセット処理が走ります。
監視が生きている間は、10秒置きの自分自身のsend命令によって、カウンタのリセット処理(エサやり)を行っていくわけですね。
赤外線通信
それに加えてpush
のコールバックでは、来たデータの値の0
/1
を判定して、エアコンへ赤外線信号(ON
/OFF
)を送信する処理を行います。
赤外線の信号自体は、赤外線受信モジュールを使ってあらかじめパターンをプログラム内に書き込んでおきます(冒頭であげた、ICS MEDIAさんの記事が大変参考になりました)。
再起動中にデータを送ってしまった場合の対処
運悪くリセットをしたタイミング等監視をしていないときにデータを送信してしまった場合、どうすればいいでしょうか。
実はこの対策のために、データの送信と同時に保存も行うpush()
APIを使っていました。Milkcocoaのデータの保存機能と、ESPの不揮発性のROMであるEEPROMを使って、以下のような対策をします(EEPROMについてはこちらの記事が参考になりました)。
- 送信するデータにidをつけて、そのデータを受け取った際に、そのidをEEPROMに書き込む。
- 起動時に、Milkcocoaのデータストアに保存されている先頭のデータを取ってきて、EEPROMにあるデータのidを比較して異なれば、取ってきたデータによる赤外線送信処理を行う
こうすれば、監視していないときにデータが来ても、起動時にそのデータを使った処理を行ってくれます。
プログラム
var milkcocoa = new MilkCocoa('あなたのapp_id.mlkcca.com');
// id用のランダム文字列 r を作成
var c = "abcdefghijklmnopqrstuvwxyz0123456789";
var r = "";
for(var i=0; i<16; i++){
r += c[Math.floor(Math.random()*c.length)];
}
// エアコンをoffにしたいなら
milkcocoa.dataStore('任意のデータストア名').push({ v:0, id:r });
// onにしたいなら
milkcocoa.dataStore('任意のデータストア名').push({ v:1, id:r });
#include <EEPROM.h>
#include <ESP8266WiFi.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <Milkcocoa.h>
/* エアコンの信号たち、赤外線受信モジュールであらかじめ解析したもので、数字はus単位の時間をあらわしている */
// エアコンオン(暖房、25度)の信号、
unsigned int ondata[] = {36,50,36,50,36,50,36,50,36,50,36,2527,346,175,36,137,36,50,36,50,36,50,36,137,36,50,36,50,36,50,36,50,36,137,36,...やたら長い数字配列};
// エアコンオフの信号
unsigned int offdata[] = {36,50,36,50,36,50,36,50,36,50,36,2527,346,175,36,137,36,50,36,50,36,50,36,137,36,50,36,50,36,50,36,50,36,137,36,...やたら長い数字配列};
struct CONFIG {
char id[32];
};
/************************* WiFi Access Point *********************************/
#define WLAN_SSID "...SSID..."
#define WLAN_PASS "...PASS..."
/************************* Your Milkcocoa Setup *********************************/
#define MILKCOCOA_APP_ID "...YOUR_MILKCOCOA_APP_ID..."
#define MILKCOCOA_DATASTORE "...任意のデータストア名..."
/************* Milkcocoa Setup (you don't need to change this!) ******************/
#define MILKCOCOA_SERVERPORT 1883
/************ Global State (you don't need to change this!) ******************/
// Create an ESP8266 WiFiClient class to connect to the MQTT server.
WiFiClient client;
const char MQTT_SERVER[] PROGMEM = MILKCOCOA_APP_ID ".mlkcca.com";
const char MQTT_CLIENTID[] PROGMEM = __TIME__ MILKCOCOA_APP_ID;
Milkcocoa milkcocoa = Milkcocoa(&client, MQTT_SERVER, MILKCOCOA_SERVERPORT, MILKCOCOA_APP_ID, MQTT_CLIENTID);
const char* host = MILKCOCOA_APP_ID ".mlkcca.com";
CONFIG buf;
// エアコンをONにする赤外線信号を送信
void sendOnSignal() {
int dataSize = sizeof(ondata) / sizeof(ondata[0]);
for (int cnt = 0; cnt < dataSize; cnt++) {
unsigned long len = ondata[cnt]*10; // dataは10us単位でON/OFF時間を記録している
unsigned long us = micros();
do {
digitalWrite(12, 1 - (cnt&1)); // cntが偶数なら赤外線ON、奇数ならOFFのまま
delayMicroseconds(8); // キャリア周波数38kHzでON/OFFするよう時間調整
digitalWrite(12, 0);
delayMicroseconds(7);
} while (long(us + len - micros()) > 0); // 送信時間に達するまでループ
}
}
// エアコンをOFFにする赤外線信号を送信
void sendOffSignal() {
int dataSize = sizeof(offdata) / sizeof(offdata[0]);
for (int cnt = 0; cnt < dataSize; cnt++) {
unsigned long len = offdata[cnt]*10; // dataは10us単位でON/OFF時間を記録している
unsigned long us = micros();
do {
digitalWrite(12, 1 - (cnt&1)); // cntが偶数なら赤外線ON、奇数ならOFFのまま
delayMicroseconds(8); // キャリア周波数38kHzでON/OFFするよう時間調整
digitalWrite(12, 0);
delayMicroseconds(7);
} while (long(us + len - micros()) > 0); // 送信時間に達するまでループ
}
}
// バイナリカウンタをリセットする
void resetCounter(int pin) {
digitalWrite(pin, LOW);
delay(30);
digitalWrite(pin, HIGH);
}
// sendがきたら呼ばれる関数
void onsend(DataElement *elem) {
resetCounter(14); // IO14をバイナリカウンタに繋いでる
Serial.println("onsend");
}
// pushがきたら呼ばれる関数
void onpush(DataElement *elem) {
resetCounter(14); // IO14をバイナリカウンタに繋いでる
String recvId = elem->getString("id"); // データから id を取得
int recvV = elem->getInt("v"); // データから v を取得
Serial.println("onpush");
Serial.println(recvV);
Serial.println(recvId);
// idをEEPROMに書き込む
const char *recvIdChar = recvId.c_str();
strcpy(buf.id, recvIdChar);
EEPROM.put<CONFIG>(0, buf);
EEPROM.commit();
// v が0ならOFF、1ならON の赤外線信号を
if(recvV==0) sendOffSignal();
else sendOnSignal();
};
void setup() {
pinMode(14, OUTPUT);
pinMode(12, OUTPUT);
resetCounter(14);
Serial.begin(115200);
Serial.println(); Serial.println(F("Milkcocoa SDK demo"));
// EEPROMからデータを取得
EEPROM.begin(100);
EEPROM.get<CONFIG>(0, buf);
Serial.println(buf.id);
delay(10);
// Connect to WiFi access point.
Serial.println(); Serial.println();
Serial.print("Connecting to ");
Serial.println(WLAN_SSID);
WiFi.begin(WLAN_SSID, WLAN_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.println("WiFi connected");
Serial.println("IP address: ");
Serial.println(WiFi.localIP());
resetCounter(14);
// connect
WiFiClientSecure client;
const int httpsPort = 443;
if (!client.connect(host, httpsPort)) {
Serial.println("Connection failed");
return;
}
// milkcocoaのデータストアからデータを取得
char* url = "/api?appid=" MILKCOCOA_APP_ID "&api=query&limit=1&sort=DESC&path=" MILKCOCOA_DATASTORE;
Serial.println("Connection success");
delay(200);
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);
// データの id と v を取得
const char *json = data.c_str();
StaticJsonBuffer<1000> jsonBuffer;
JsonObject& root = jsonBuffer.parseObject(json);
String gotId = root["id"];
int gotValue = root["v"];
Serial.print("Get value: ");
Serial.println("id: " + gotId);
Serial.println("v: " + String(gotValue));
Serial.println("***************");
// 取ってきたデータとEEPROMのデータのidが異なれば赤外線送信を実行
if(!gotId.equals(buf.id)){
if(gotValue==0) sendOffSignal();
else sendOnSignal();
const char *gotIdChar = gotId.c_str();
strcpy(buf.id, gotIdChar);
EEPROM.put<CONFIG>(0, buf);
EEPROM.commit();
}
// Subscribe(pushとsendを監視)
Serial.println( milkcocoa.on(MILKCOCOA_DATASTORE, "push", onpush) );
Serial.println( milkcocoa.on(MILKCOCOA_DATASTORE, "send", onsend) );
};
int counter = 0;
void loop() {
milkcocoa.loop();
DataElement elem = DataElement();
elem.setValue("v", 1);
// エサやり用のsend命令
if(counter%5 == 0) milkcocoa.send(MILKCOCOA_DATASTORE, &elem);
counter++;
if(counter == 99) counter = 0;
// milkcocoaの監視が1秒待つようになっているので、プラス1秒のdelayで合計2秒
delay(1000);
};
課題
わざわざサブスクライブしなくても...
一人暮らしの遠隔リモコンとして使うのであれば、前回までと同様、スリープして10分おきとかに起きてデータが変わってるか確認して変わってたら赤外線通信、ぐらいで正直十分です。むしろ電力も抑えられてこっちが良い気がします。
ただそれだと代わり映えしないし、今回はどちらかというと「安定してサブスクライブする方法」の紹介をしたかったので、それは無しにしました。
データが頻繁にくるケース
起動時にはとってきたデータの先頭しか確認していないので、監視していないときに2つ以上データが送信された場合は、2つ目以降は無視されます。
まあ、今回のエアコンの例だと、エアコンの状態が最終的にどうなっているかにしか関心が無いので、それで全然構わないんですけどね。他の例で応用するときは、工夫して下さい。
おわりに
1ヶ月くらい問題なく使えるかどうか試して、不具合が出なかったか、1ヶ月後、この記事に追記するかたちで報告しようかと思います(定型文)。
2016-06-15 追記:2ヶ月半くらい経っちゃいました。結論から言うと、問題はほぼなかったです。
稼働はずっとしてくれていて、命令を送ったらちゃんと反応してくれました。
ただ、プログラムに1個問題があって、Web上のデータをとってこれなかったときに取得をやり直すように書いてなかったので、そこのプログラムを修正する必要がありますね。
まあ正直、作ってから最近までエアコンを使う機会自体なかったので、これから暑くなってきてから活躍してくれることを期待してます。