はじめに
myThingsはヤフーが2015年7月に提供を開始したIoTサービスです。天気・災害総合サービスの「Yahoo!天気・災害」や画像共有サービス「Instagram」、活動量計の「UP」のような様々なチャンネルをトリガーやアクションとし、スマートフォンアプリを用いてそれらの組み合わせを自分で作成することで、自分専用のサービスをつくることができます。くわえて、ヤフーのグループ会社であるIDCフロンティアが運営するクラウドサービス「IDCF Cloud」上にサーバーを用意することにより、「IDCFチャンネル」として独自のチャンネルを用意することもできます。
この記事では、チーム・ベゼリーが開発中のロボット作成キット「ベゼリー開発キット(BDK)」と小型で安価なWi-Fiモジュール「ESP-WROOM-02」、IDCF Cloudを使い、Google カレンダーに登録した予定の時間が近づくと動きで教えてくれる通知ロボットをつくります。
準備
IDCF Cloud
IDCF Cloudのセットアップに関してはIDCF Cloudのウェブサイトでの公式な説明「myThingsをはじめよう」を参照してください。
Arduino IDEとESP-WROOM-02
ここではESP-WROOM-02モジュールを搭載した開発ボードとしてスイッチサイエンスの「ESP-WROOM-02開発ボード」を使用しています。Arduino IDEとESP-WROOM-02開発ボードのセットアップに関しては以前の記事「ESP-WROOM-02開発ボードでmyThingsのIDCFチャンネルを試す」を参照してください。
BDK
BDKは3個のサーボモータと3Dプリンティングによる機構部品から構成されるロボットキットです。この記事を書くにあたっては、チーム・ベゼリーから評価用として提供を受けたBDKを使用しています。残念ながらこの記事を書いている時点ではBDKはまだ一般向けには発売されていません。ぜひ入手したいという方は、ウェブサイトやFacebookページで最新の情報を入手されることをおすすめします。
なお、BDKで使用されているサーボモータは入手しやすいTowerProのデジタルサーボ「SG90」を使用しています。このサーボと互換性のあるロボットキットであれば簡単に応用できると思います。
実装
組み合わせ
iOSまたはAndroid用のmyThingsアプリを用いて、Google カレンダーをトリガーに、IDCFチャンネルのアクション1をアクションに指定した組み合わせを作成します。この時、アクションの「メッセージ」には通知するメッセージを入力します。このメッセージを使い分けることにより、カレンダーの種類や予定タイトルに応じてベゼリーに異なる反応をさせることができます。
組み合わせ作成時にメッセージを空欄にした場合、受信するメッセージは次のようになります。メッセージを指定した場合には、その内容がpayloadに入ります。
{"topic":"message","data":{"devices":["********-****-****-****-************"],"payload":"","fromUuid":"********-****-****-****-************"}}
なお、Google カレンダーをトリガーにした場合、テストするために手動実行しても「条件にマッチするトリガーがありませんでした」と表示されるだけで確認ができません。Google カレンダーの少し先の時間に予定を作成し、通知の予定時刻(例:16:00からの予定に対して5分前に設定したのであれば15:55)が過ぎたところで手動実行するようにすると良いでしょう。
ESP-WROOM-02
ESP-WROOM-02からはMQTTでIDCF Cloud上に準備したサーバに接続します。基本的なコードに関しては「ESP-WROOM-02開発ボードでmyThingsのIDCFチャンネルを試す」で紹介したものと同じですので、ベゼリーをコントロールするために新たにつくったクラス「Bezelie」と、MQTTで受け取ったメッセージを処理する部分に絞って説明します。
なお、このサンプルのソースコードはGitHubで公開しています(https://github.com/kotobuki/esp8266_examples)。
Bezelieクラス
ベゼリーには3個のサーボが搭載され、ピッチ(左右を軸にした回転軸で首を上下に振る)、ロール(前後を軸にした回転軸で首を左右に振る)、ヨー(上下を軸にした回転軸でボディ全体を左右に振る)の3つの軸で回転させることができます。
このサンプルではDIO2をピッチ軸用、DIO12をロール軸用、DIO13をヨー軸用のサーボにそれぞれ接続し、あらかじめ設定したシーケンスを実行するようにしています。原点となる位置は、ピッチ、ロール、ヨー、それぞれ90度です。シーケンスのデータとしては、ピッチ、ロール、ヨーのそれぞれの軸に関する原点からの相対角度(±90度)と次のアクションに移るまでの間隔(単位はミリ秒)です。最後の間隔としてEND_OF_SEQUENCEを与えると、そのステップをシーケンスの終わりとして認識します。
#ifndef Bezelie_h
#define Bezelie_h
#include "Arduino.h"
#include "Servo.h"
#define PITCH 0
#define ROLL 1
#define YAW 2
#define DELTA 3
#define MAX_STEPS 16
#define END_OF_SEQUENCE -1
const int defaultPitch = 90;
const int defaultRoll = 90;
const int defaultYaw = 90;
// pitch, roll, yaw, delta
const int _sequence[][MAX_STEPS][4] = {
{
// Say yes
{ +30, 0, 0, 100},
{ -30, 0, 0, 200},
{ +30, 0, 0, 200},
{ 0, 0, 0, 100},
{ 0, 0, 0, END_OF_SEQUENCE}
},
// Say no
{
{ 0, -30, 0, 100},
{ 0, +30, 0, 200},
{ 0, -30, 0, 200},
{ 0, 0, 0, 100},
{ 0, 0, 0, END_OF_SEQUENCE},
}
};
class Bezelie
{
public:
Bezelie();
void begin(int pitchPin, int rollPin, int yawPin);
void startMotion(int motion);
void stopMotion();
bool isInMotion();
void update();
private:
Servo _pitchServo;
Servo _rollServo;
Servo _yawServo;
int _motion;
int _count;
int _deltaTime;
unsigned long _lastTime;
bool _isInMotion;
};
#endif
#include "Bezelie.h"
Bezelie::Bezelie()
{
_isInMotion = false;
}
void Bezelie::begin(int pitchPin, int rollPin, int yawPin)
{
_pitchServo.attach(pitchPin);
_rollServo.attach(rollPin);
_yawServo.attach(yawPin);
_pitchServo.write(90);
_rollServo.write(90);
_yawServo.write(90);
}
void Bezelie::startMotion(int motion)
{
_motion = motion;
_count = 0;
_lastTime = millis();
_pitchServo.write(defaultPitch + _sequence[_motion][_count][PITCH]);
_rollServo.write(defaultRoll + _sequence[_motion][_count][ROLL]);
_yawServo.write(defaultYaw + _sequence[_motion][_count][YAW]);
_deltaTime = _sequence[_motion][_count][DELTA];
_isInMotion = true;
Serial.println("Motion started");
}
void Bezelie::stopMotion()
{
_pitchServo.write(90);
_rollServo.write(90);
_yawServo.write(90);
_isInMotion = false;
}
bool Bezelie::isInMotion(){
return _isInMotion;
}
void Bezelie::update()
{
if (!_isInMotion) {
return;
}
unsigned long elapsedTime = millis() - _lastTime;
if (elapsedTime > _deltaTime) {
_count++;
if (_sequence[_motion][_count][DELTA] == END_OF_SEQUENCE) {
_isInMotion = false;
Serial.println("Motion finished");
} else {
_lastTime = millis();
_pitchServo.write(defaultPitch + _sequence[_motion][_count][PITCH]);
_rollServo.write(defaultRoll + _sequence[_motion][_count][ROLL]);
_yawServo.write(defaultYaw + _sequence[_motion][_count][YAW]);
_deltaTime = _sequence[_motion][_count][DELTA];
}
}
}
新しいライブラリとしてTickerを使用しています。これは、定期的に指定した内容を実行させるものです。ここでは、Bezelieのupdateメソッドを0.1秒ごとに実行するために使用しています。
void updateBezelie() {
bezelie.update();
}
void loop() {
...
// クライアントがサーバに接続されていなければ
if (!client.connected()) {
// アクション1のUUIDとトークンをユーザ名およびパスワードとしてサーバに接続
client.connect(mqtt_client_id, action_1_uuid, action_1_token);
if (client.connected()) {
Serial.print("MQTT connected: ");
Serial.println(server);
client.setCallback(callback);
client.subscribe(action_1_uuid);
// Pitch: 2
// Roll: 12
// Yaw: 13
bezelie.begin(2, 12, 13);
ticker.attach(0.1, updateBezelie);
Serial.println("Ready to roll");
} else {
Serial.print("MQTT connection failed: ");
Serial.println(client.state());
delay(5000);
}
} else {
// 既にサーバに接続されていれば通常処理を行う
client.loop();
}
}
MQTTメッセージの処理
void callback(char* topic, byte* payload, unsigned int length) {
// PubSubClient.hで定義されているMQTTの最大パケットサイズ
char buffer[MQTT_MAX_PACKET_SIZE];
snprintf(buffer, sizeof(buffer), "%s", payload);
Serial.print("Received: ");
Serial.println(buffer);
// 受け取ったJSON形式のペイロードをデコードする
StaticJsonBuffer<MQTT_MAX_PACKET_SIZE> jsonBuffer;
JsonObject& root = jsonBuffer.parseObject(buffer);
if (!root.success()) {
Serial.println("parseObject() failed");
return;
}
String parsedPayload = String((const char*)root["data"]["payload"]);
if (parsedPayload != NULL) {
Serial.print("payload: ");
Serial.println(parsedPayload);
// 受け取ったペイロードの中身に応じて対応するモーションを再生
if (parsedPayload.equals("もうすぐ予定の時間です")) {
bezelie.startMotion(0);
} else {
bezelie.startMotion(1);
}
}
}
既知の問題
通知の実行タイミング
myThingsの通知が実行されるのは15分間隔です。このため、例えば16:00に予定を作成し、5分前に通知されることを期待していると、実際に通知されるのは16:00を少し過ぎた辺りになってしまいます。これは、15分間隔で順に全てのユーザが作成した全ての組み合わせについてmyThingsサーバがトリガーの発生条件にマッチしているかどうかを確認し、マッチしていた場合に該当するアクションを発火させる仕組みになっているためです。将来的には間隔をもっと短くするようなオプションが提供されるかもしれませんが、さしあたっては通知のタイミングは15分前以上に設定するのが実用的ではないかと思われます。この点に関しては要確認
サーボのdetach
このサンプルでは一度サーボに対して一度attachした後、そのまま使い続けています。無駄にサーボを動作させて電力を消費するだけで無く、サーボそのものを消耗させることになりますので、本来であれば、通知する間だけサーボに対して制御信号を送り、それ以外は制御信号をとめてサーボをオフにすべきです。Arduino core for ESP8266 WiFi chipの2.0.0においては一度サーボをdetachしてしまうと再度attachできないというバグがあるためこのようにしています。
将来的にこのバグが解消したときには、必要なときだけサーボに制御信号を送るように変更すると良いでしょう。もしくは、通知した後にESP.restart()を使用してリセットするというちょっと乱暴な方法もアリかもしれません。
サーボ使用時のシリアル出力
原因は不明ですが、サーボを使用している間、Serial.print()でのシリアル出力をArduino IDEのシリアルモニタでモニタしていても正常に更新されなくなることがあるようです。
おわりに
まだまだ改善の余地のあるサンプルではありますが、myThingsとESP8266、BDKという使いやすい「部品」が登場したことにより、それらを組み合わせるだけでこうしたロボットをとても簡単につくれるようになったことを実感していただけたら幸いです。