auが出してる「今日傘を持っていく必要があるかどうか」をLEDの色でお知らせしてくれる傘立て「Umbrella Stand」を、ESP8266を使って試作しました。
それなりにちゃんと「使えるもの」を作ったつもりです。というのも、IoTの電子工作をやってると、だんだん
- 安定して動作しないものを作っても最終的に満足感が得られない
- 無駄に通信しまくってアホみたいに消費電力食う&部屋の通信を使うのが気に食わない
- 見た目がメカメカしてると生活の中で使うのが躊躇われる
とか思ってくるんですね。「とりあえず動作した」では満足できなくなってきます、当たり前っちゃ当たり前なんですが。
要は、こちらの記事の段階でいうところの、実用の壁を越えるまではいかなくても実用付近までいきたいのです。おうちハックの位置ですかね。
作ったIoTデバイスが社会に普及するまでに、いくつか壁がある。趣味で作ってみたレベルから実用レベルに至るまでには、入念なバグ取りや保守性、安定性が求められる。プロトタイプから、日常生活でちゃんと不都合なく使えるレベルにするだけで結構大変だったりする。
Webに投稿されてるノウハウは割と、実用未満のノウハウが多いように感じます。
なので、今回は上述の点をクリアしたIoTプロトタイプが出来ないか、auのIoT製品である「Umbrella Stand」を作ってみることで試しました。
機能
1時間ごとに起動して、インターネット経由でユーザー(私)が指定した地域の 現在時刻から9〜12時間後までその日の 降雨量予想を取得し、降雨量に応じてLEDの色を変えてスリープする。
※2016/02/10 追記:3時間ごとの予報でうまくデータがとれなくなってたので24時間予報に変えました。
※2016/02/15 追記:3時間ごとの予報が復活してました。メンテナンスか何かですかね、にしては長いような...
システム
天気API
OpenWeatherMapを使います。無償です。
OpenWeatherMapの5 day / 3 hour forecast(3時間ごとの予報)で、直近の3つのデータ(9時間分)をとってきます。外出して帰ってくるまでと考えたら9時間あれば十分でしょう。
※2016/02/10 追記:前述の通り、3時間ごとの予報ではなく、24時間予報で1つのデータを取ってくる方法に変えました。
※2016/02/15 追記:3時間ごとの予報が復活していたので、現在はどちらの方法でも大丈夫です。
以下のように緯度(lat
)・経度(lon
)とOpenWeatherMapのapp_id
と取ってくるデータ数(cnt
)を指定してデータを取ってきます。
{
"city":{
"id":1859740,
"name":"Kawagoe",
"coord":{
"lon":139.485275,
"lat":35.908611
},
"country":"JP",
"population":0,
"sys":{
"population":0
}
},
"cod":"200",
"message":0.0083,
"cnt":3,
"list":[
{
"dt":1453518000,
"main":{
"temp":279.59,
"temp_min":275.696,
"temp_max":279.59,
"pressure":991.65,
"sea_level":1032.48,
"grnd_level":991.65,
"humidity":58,
"temp_kf":3.9
},
"weather":[
{
"id":800,
"main":"Clear",
"description":"sky is clear",
"icon":"01d"
}
],
"clouds":{
"all":24
},
"wind":{
"speed":1.22,
"deg":112.505
},
"snow":{
"3h":0.004
},
"sys":{
"pod":"d"
},
"dt_txt":"2016-01-23 03:00:00" // 日本ではこれに+9時間
},
{
"dt":1453528800,
"main":{
"temp":278.96,
"temp_min":276.035,
"temp_max":278.96,
"pressure":988.71,
"sea_level":1029.21,
"grnd_level":988.71,
"humidity":61,
"temp_kf":2.92
},
"weather":[
{
"id":600,
"main":"Snow",
"description":"light snow",
"icon":"13d"
}
],
"clouds":{
"all":24
},
"wind":{
"speed":1.61,
"deg":104.001
},
"snow":{
"3h":0.033
},
"sys":{
"pod":"d"
},
"dt_txt":"2016-01-23 06:00:00"
},
{
"dt":1453539600,
"main":{
"temp":272.94,
"temp_min":270.993,
"temp_max":272.94,
"pressure":987.24,
"sea_level":1027.68,
"grnd_level":987.24,
"humidity":85,
"temp_kf":1.95
},
"weather":[
{
"id":500,
"main":"Rain",
"description":"light rain",
"icon":"10n"
}
],
"clouds":{
"all":44
},
"wind":{
"speed":1.2,
"deg":22.503
},
"rain":{
"3h":0.01
},
"snow":{
"3h":0.185
},
"sys":{
"pod":"n"
},
"dt_txt":"2016-01-23 09:00:00"
}
]
}
"list"
の中に3時間ごとのデータが配列で3つ入っていて、その中の"rain"
の中の{"3h":0.01}
っていうところが降雨量です。雪であれば"snow":{"3h":0.185}
というふうになります("snow"と"rain"が両方現れることあります)。
※2016/02/10 追記:現在は以下のURLを使っています。
{
"city":{
"id":524901,
"name":"Moscow",
"coord":{
"lon":37.615555,
"lat":55.75222
},
"country":"RU",
"population":0
},
"cod":"200",
"message":0.0287,
"cnt":1,
"list":[
{
"dt":1455094800,
"temp":{
"day":273.5,
"min":271.67,
"max":273.5,
"night":272.52,
"eve":271.67,
"morn":272.69
},
"pressure":1008.5,
"humidity":0,
"weather":[
{
"id":600,
"main":"Snow",
"description":"light snow",
"icon":"13d"
}
],
"speed":7.32,
"deg":189,
"clouds":81,
"snow":0.9
}
]
}
※2016/02/10 追記:"rain"、"snow"に入っている値が降雨量です。
つまずきポイントとして、"rain":
のkeyごと消えることがあるので注意です。
※2016/02/10 追記:24時間予報版もつまずきポイントは同じで、rain
やsnow
のkeyの有無がきまぐれです。
{"city":..., "list": [
{"dt":...,"rain":{"3h":0.032},...}
{"dt":...,"rain":{"3h":0.247},...}
{"dt":...,"rain":{"3h":1.223},...}
], ...}
{"city":..., "list": [
{"dt":...,"rain":{"3h":0.032},...}
{"dt":...,"rain":{},...}
{"dt":...,"rain":{},...}
], ...}
// "rain"が空オブジェクトになる場合もあれば
{"city":..., "list": [
{"dt":...,"rain":{"3h":0.032},...}
{"dt":...,...}
{"dt":...,...}
], ...}
// "rain"ごとなくなることがある
※ちなみに、データは指定した緯度経度に一番近い、OpenWeatherMapに登録されている地名のデータが使用されます。地名の一覧は以下です(JPで検索をかけると日本の地名が出てきます)。
こちらに登録されている地名を使えば以下のように、緯度経度ではなく地名でクエリをかけることもできます。
ソフトウェア
ESP8266でOpenWeatherMapにHTTP GETして、プログラム内で降雨量を抽出&「晴・曇・雨」判定(以下)します。
- 降雨量が1mmを超えたら雨(傘持っていけ)
- 0〜1mmの間だったら曇(傘は要らないけど洗濯物は干す必要ない)
- 0mmだったら晴(洗濯物干そう)
降雨量の目安は以下を参考にしました。
ハードウェア
判定した値(晴・曇・雨)をESP8266の3つのGPIOに対応させて、さらにそれぞれをフリップフロップに記憶させます。フリップフロップの出力がそれぞれLED(晴→オレンジ、曇→白、雨→青)につながっています。
フリップフロップが値を記憶するので、ESP8266はフリップフロップに値を渡したら心置きなく眠ります。(deepSleepについてはこちらの記事が大変参考になりました)。
※流してる電流がICの定格のギリギリなのでちゃんとやる場合はトランジスタをお使い下さい。
必要なもの・見た目
見た目的に必要な物
- ideaco ゴミ箱 mini TUBELOR ホワイトと、穴あけ用のホットナイフ(半田ごてと兼用のやつを一つ持っておくと便利かと)
- トレーシングペーパー(LEDの光をぼんやりさせる用)
- ビニール袋(基板を隠す&機械を傘の水滴から守る)
ideacoのゴミ箱がなかなか優秀です。土台とカバーに分かれているおかげで、LEDを出すための穴・MicroUSBケーブルを通すための穴を(プラスチック加工ド素人が汚く)あけてもカバーをかぶせてしまえば見えません。見た目の部分はこのゴミ箱にかなり助けられました。
LEDたちの穴、さらに汚い。本当はもっと下に空けたかったです。そうすると、光源が丸わかりの丸い光ではなくトレーシングペーパーにふわっと光が広がります。
カバーをかぶせて隠す。下から漏れる光もおしゃれにするためにカバーにもトレーシングペーパーを貼ってます。
機能的に必要な物
- ユニバーサル基板とハンダ付けに必要なもろもろ
- ESP-WROOM-02(ESP8266)開発ボードと、ピンソケット
- Dフリップフロップ(74HC273)と、20本足丸ピンICソケット
- LED3つ(オレンジ、青、白)とその電流制御用抵抗(47Ω、4.7Ω、4.7Ω)
- MicroUSBケーブル
Umbrella Stand本家は電池駆動みたいですが、持ち運ぶ必要はあんまり無いので開発ボードのまんまの給電スタイル(普通にMicroUSBから給電するかたち)にしました。
回路図・配線
回路図というかイメージ図です。deepSleepを使うためにRST
とIO16
を繋いでいます。
先ほどの写真のように、ESP8266とフリップフロップをユニバーサル基板で配線して、LEDは基板からのばしたワイヤーにくっつけてます。基板の裏側の写真が無いのは察して下さい。
プログラム
以下は、3時間予報版。
#include <ESP8266WiFi.h>
#include <ArduinoJson.h>
// pin
#define CLK 13
#define RAINY 14
#define CLOUDY 12
#define SUNNY 4
const char* ssid = "SSID";
const char* password = "PASS";
const char* host = "api.openweathermap.org";
float rainfall[6];
int dayCounter = 0;
int dataCounter = 0;
void setup() {
Serial.begin(115200);
delay(10);
Serial.println();
Serial.println();
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 > 50) ESP.deepSleep(2 * 1000 * 1000);
delay(50);
}
Serial.println("");
Serial.println("WiFi connected");
Serial.println("IP address: ");
Serial.println(WiFi.localIP());
delay(1000);
// pin setup
pinMode(CLK, OUTPUT);
pinMode(SUNNY, OUTPUT);
pinMode(CLOUDY, OUTPUT);
pinMode(RAINY, OUTPUT);
Serial.print("connecting to ");
Serial.println(host);
// connect
WiFiClient client;
const int httpPort = 80;
if (!client.connect(host, httpPort)) {
Serial.println("connection failed");
delay(1000);
ESP.deepSleep(2 * 1000 * 1000);
delay(1000);
return;
}
String url = "/data/2.5/forecast?lat=(緯度)&lon=(経度)&appid=(あなたのOpenWeatherMapのappid)&cnt=3";
Serial.print("Requesting URL: ");
Serial.println(url);
// GET
client.print(String("GET ") + url + " HTTP/1.1\r\n" +
"Host: " + host + "\r\n" +
"Connection: close\r\n\r\n");
delay(10);
String data[6];
boolean write_flag = false;
int i;
while(client.available()){
String line = client.readStringUntil('\r');
int lineLength = line.length();
const char *lineChar = line.c_str();
// 降雨量だけdata[]に抜き出す
for(i = 0; i < lineLength; i++) {
if(write_flag){
data[dataCounter] = data[dataCounter] + lineChar[i];
if(lineChar[i] == '}'){
write_flag = false;
dataCounter++;
}
}
if(lineChar[i-5] == 'r' && lineChar[i-4] == 'a' && lineChar[i-3] == 'i' && lineChar[i-2] == 'n' && lineChar[i-1] == '"' && lineChar[i] == ':'){
write_flag = true;
}
if(lineChar[i-5] == 's' && lineChar[i-4] == 'n' && lineChar[i-3] == 'o' && lineChar[i-2] == 'w' && lineChar[i-1] == '"' && lineChar[i] == ':'){
write_flag = true;
}
// 3つ(9時間分)のデータが取れているかどうかの判定用カウンタ(判定する文字列はなんでも良かったけど今回は "weather": を使用)
if(lineChar[i-9] == '"' && lineChar[i-8] == 'w' && lineChar[i-7] == 'e' && lineChar[i-6] == 'a' && lineChar[i-5] == 't' && lineChar[i-4] == 'h' && lineChar[i-3] == 'e' && lineChar[i-2] == 'r' && lineChar[i-1] == '"' && lineChar[i] == ':'){
dayCounter++;
}
}
Serial.println("dataCounter: " + String(dataCounter));
Serial.println("dayCounter: " + String(dayCounter));
// JSONデータから数字だけrainfall[]に抽出
for(i = 0; i < dataCounter; i++) {
Serial.println(data[i]);
const char *json = data[i].c_str();
StaticJsonBuffer<1000> jsonBuffer;
JsonObject& root = jsonBuffer.parseObject(json);
rainfall[i] = root["3h"];
Serial.print("Get value: ");
Serial.println(rainfall[i], 4);
}
}
Serial.println();
Serial.println("End of setup");
}
void loop() {
// フリップフロップの初期化(すべてLOWに)
digitalWrite(CLK, LOW);
delay(50);
digitalWrite(SUNNY, LOW);
digitalWrite(CLOUDY, LOW);
digitalWrite(RAINY, LOW);
delay(50);
digitalWrite(CLK, HIGH);
// 3つ(9時間分)のデータがとれていなかったら再起動(2秒スリープ後リセット)
delay(100);
if(dayCounter < 3) ESP.deepSleep(2 * 1000 * 1000);
delay(100);
digitalWrite(CLK, LOW);
int pin = SUNNY;
for(int i = 0; i < dataCounter; i++) {
// 降雨量が1mm以上だったら雨判定
if(rainfall[i] >= 1){
pin = RAINY;
break;
}
// 降雨量が1mm未満だったら曇り判定
if(0 < rainfall[i] && rainfall[i] < 1) pin = CLOUDY;
}
delay(100);
digitalWrite(pin, HIGH);
delay(100);
digitalWrite(CLK, HIGH);
delay(1000);
Serial.println("DEEP SLEEP START!!");
// 1時間(μ秒指定。ちなみにdeepSleepの引数はuint32_tなので1.2時間弱くらいまで指定可能です)
ESP.deepSleep(3600000000);
//deepsleepモード移行までのダミー命令
delay(1000);
Serial.println("DEEP SLEEPing....");
}
以下は、24時間予報版。
#include <ESP8266WiFi.h>
#include <ArduinoJson.h>
// pin
#define CLK 13
#define RAINY 14
#define CLOUDY 12
#define SUNNY 4
const char* ssid = "ssid";
const char* password = "pass";
const char* host = "api.openweathermap.org";
float rainfall[6];
int dayCounter = 0;
int dataCounter = 0;
void setup() {
Serial.begin(115200);
delay(10);
Serial.println();
Serial.println();
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 > 50) ESP.deepSleep(2 * 1000 * 1000);
delay(50);
}
Serial.println("");
Serial.println("WiFi connected");
Serial.println("IP address: ");
Serial.println(WiFi.localIP());
delay(1000);
// pin setup
pinMode(CLK, OUTPUT);
pinMode(SUNNY, OUTPUT);
pinMode(CLOUDY, OUTPUT);
pinMode(RAINY, OUTPUT);
Serial.print("connecting to ");
Serial.println(host);
// connect
WiFiClient client;
const int httpPort = 80;
if (!client.connect(host, httpPort)) {
Serial.println("connection failed");
delay(1000);
ESP.deepSleep(2 * 1000 * 1000);
delay(1000);
return;
}
String url = "/data/2.5/forecast/daily?lon=経度&lat=緯度&appid=あなたのapp_id&cnt=1";
Serial.print("Requesting URL: ");
Serial.println(url);
// GET
client.print(String("GET ") + url + " HTTP/1.1\r\n" +
"Host: " + host + "\r\n" +
"Connection: close\r\n\r\n");
delay(10);
String data[6];
boolean write_flag = false;
int i;
while(client.available()){
String line = client.readStringUntil('\r');
int lineLength = line.length();
const char *lineChar = line.c_str();
// 降雨量だけdata[]に抜き出す
for(i = 0; i < lineLength; i++) {
if(write_flag){
if(lineChar[i] == '}' || lineChar[i] == ','){
data[dataCounter] = data[dataCounter] + '}';
write_flag = false;
dataCounter++;
} else {
data[dataCounter] = data[dataCounter] + lineChar[i];
}
}
if(lineChar[i-5] == 'r' && lineChar[i-4] == 'a' && lineChar[i-3] == 'i' && lineChar[i-2] == 'n' && lineChar[i-1] == '"' && lineChar[i] == ':'){
write_flag = true;
data[dataCounter] = "{\"24h\":";
}
if(lineChar[i-5] == 's' && lineChar[i-4] == 'n' && lineChar[i-3] == 'o' && lineChar[i-2] == 'w' && lineChar[i-1] == '"' && lineChar[i] == ':'){
write_flag = true;
data[dataCounter] = "{\"24h\":";
}
// 1日分のデータが取れているかどうかの判定用カウンタ(判定する文字列はなんでも良かったけど今回は "weather": を使用)
if(lineChar[i-9] == '"' && lineChar[i-8] == 'w' && lineChar[i-7] == 'e' && lineChar[i-6] == 'a' && lineChar[i-5] == 't' && lineChar[i-4] == 'h' && lineChar[i-3] == 'e' && lineChar[i-2] == 'r' && lineChar[i-1] == '"' && lineChar[i] == ':'){
dayCounter++;
}
}
Serial.println("dataCounter: " + String(dataCounter));
Serial.println("dayCounter: " + String(dayCounter));
// JSONデータから数字だけrainfall[]に抽出
for(i = 0; i < dataCounter; i++) {
Serial.println(data[i]);
const char *json = data[i].c_str();
StaticJsonBuffer<1000> jsonBuffer;
JsonObject& root = jsonBuffer.parseObject(json);
rainfall[i] = root["24h"];
Serial.print("Get value: ");
Serial.println(rainfall[i], 4);
}
}
Serial.println();
Serial.println("End of setup");
}
void loop() {
// フリップフロップの初期化(すべてLOWに)
digitalWrite(CLK, LOW);
delay(50);
digitalWrite(SUNNY, LOW);
digitalWrite(CLOUDY, LOW);
digitalWrite(RAINY, LOW);
delay(50);
digitalWrite(CLK, HIGH);
// データがとれていなかったら再起動(2秒スリープ後リセット)
delay(100);
if(dayCounter < 1) ESP.deepSleep(2 * 1000 * 1000);
delay(100);
digitalWrite(CLK, LOW);
int pin = SUNNY;
for(int i = 0; i < dataCounter; i++) {
// 降雨量が1mm以上だったら雨判定
if(rainfall[i] >= 1){
pin = RAINY;
break;
}
// 降雨量が1mm未満だったら曇り判定
if(0 < rainfall[i] && rainfall[i] < 1) pin = CLOUDY;
}
delay(100);
digitalWrite(pin, HIGH);
delay(100);
digitalWrite(CLK, HIGH);
delay(1000);
Serial.println("DEEP SLEEP START!!");
// 1時間強、int型の範囲限界近く(μ秒指定、ちなみに3600000000秒(1時間)と指定してもちょうど1時間にはなってくれません)
ESP.deepSleep(4200000000);
//deepsleepモード移行までのダミー命令
delay(1000);
Serial.println("DEEP SLEEPing....");
}
ポイントは、データがちゃんと取得できていなかったらすぐに再起動する点です。
成功したときのみプログラムを実行するようにすることで、「通信の不具合でデータが取得できないままスリープしちゃった」という事態を回避します。
冒頭で挙げた課題たち
冒頭で挙げた課題を解決できているか見てみます。
1. 安定して動作するか?
前述の通り、成功したときのみプログラムを実行するようにしています。
- WiFiに接続できずに一定時間経ったらリセット
- OpenWeatherMapへのコネクションが確立できなかったらリセット
-
3つ(9時間分)のデータがちゃんととれていなかったらリセット
通信の障害で失敗する分にはリセットしてくれるので安心です。
あとは、予期せぬ内部的な例外です。これもずっとループを回しているわけではなく「眠→リセットで起動→一瞬処理する→眠」を1時間ごとに行っているので、そんなに出ることはないかなあと思っています。
ハード側の劣化についてはちょっと詳しくないので何とも言えません。。
2. 無駄に通信して消費電力を食わないか?
1時間ごとに起きて役割が終わったら眠るので、ESPの消費電力は最低限かなと思います(こちらの記事によるとdeepSleep時に利用される電流は10uA)。
どっちかというと、24時間LEDを点けっぱなしにしてる方が問題です。焦電とか赤外線とかそのへんで近づいたときだけ点くようにすべきですね。
3. 見た目はメカメカしくないか?
外見はゴミ箱からケーブルが生えているだけのものです(トレーシングペーパーをテープで貼ってるのがちょっと手作り感ありますが)。
これを実家の母親に「使って」と言ってもそんなにいやがられることはないかなあと思っています。
単純に傘立てとしての使い勝手が良いかについては微妙ですが。。
まとめ
一応、課題をクリアしたUmbrella Standの試作は出来ました。
と言いつつ実際に運用していないので、1ヶ月くらい問題なく使えるかどうか試さないことには、「成功した」とは言えないですね。
1ヶ月後、この記事に追記するかたちで報告しようかと思います。
※2016/01/18 追記:雪が降る前日、家に帰ると青色に怪しく光っていました。これでとりあえず3色とも確認が出来ました。
追記(2016-02-20):1ヶ月使ってみました。
結論から言うと、ほぼ問題なく使えました。
2週間前くらいにLEDが消えてしまってたんですが、原因を調べるとWiFiルータの不具合でWiFiのアクセスポイントが死んでたせいでした。
10日前には、LEDが2色点灯しちゃってたんですが、原因を調べるとOpenWeatherMapのAPIでデータが取れないバグが起きていました。今は直っているようです。
今でも、ちょっとの間だけLEDが2色点灯して、置いとくとまた1色に戻るっていうことが起こるんですが原因がわかっていません。というより、生活に支障はないので放置しています。
天気予報の精度については、3時間版のほうは結構的確で、言う通りにやっていたら(傘持っていったり・持っていかなかったりしたら)基本大丈夫でした。自分から能動的に調べなくてもわかるっていうのは、思ったより便利で結構快適でした。
24時間版に変えてからは、24時間分の降雨量で1mm以上と判定しているので、深夜に小雨が降るだけの日でも「傘を持っていけ」と言われて、「傘持ってるの自分だけじゃん」っていう状況に1回だけなりました(笑) 時間があるときに3時間版に戻そうと思っています。
結果としては、「止まりはしたが原因が外部要因だった」って感じですかね。サービスやWiFiがちゃんと動いていれば安定して動いたと思います。
ちなみに
秋月電商で、超アナログお天気センサーキットっていうのが売ってます。
「インターネットに繋ぐ必要がないものは繋がない方が良い」っていう話もありますよね。