この記事は、おうちハック Advent Calendar 2017の16日目の記事です。
#1. はじめに
自作の照明制御サービスを作って、スマートスピーカーと連携させてみましたので、その内容をここでまとめていきます。
#2. スマートスピーカーの概要
説明するまでもないでしょうが、スマートスピーカーとは対話型の音声操作に対応したスピーカーのことです。たまたにAIスピーカーと表現されている場合がありますが、スピーカー自体にAIが搭載されているわけではないです。AIスピーカーがというかたはこちらをどうぞ。
さて、本題。
現在、我が家では以下の4つのスマートスピーカーを使っています。
名称 | 会社 | サービス開発プラットフォーム | 赤外線 |
---|---|---|---|
Clova WAVE | Line | Clova Extension Kit | あり |
Echo dot | Amazon | Alexa, Lambda | なし |
Google Home/Google Home mini | Action on Google, Dialogflow | なし | |
Clova Waveは唯一、赤外線を搭載しており、この記事を書いている時点(2017.12上旬)ではテレビ(電源・チャンネル・音量)と照明(オンオフ)が可能です。ただ、学習リモコンではないので、公式が対応したものだけになります。残念ながら我が家の照明リモコンには対応していませんでした。がっくり。
Clova Waveのサービス開発プラットフォームであるClova Extension Kitは、現状では一般ユーザには解放されていないので、Clova Waveでは自作のサービスを使うことができません。
私はもってませんが、Echo Plusはスマートホーム・ハブを内蔵しZigBeeでの通信が可能です。ただし、発売時に対応しているのはPhilipseのHueのみ。Hueだけなら、Hueブリッジを買えば他のAmazon Echo機種やGoogle Homeでもつなげられますが、EchoとEcho Plusの価格差は6000円、Hueブリッジの価格は7400円とみるとお得ではあります。Hueブリッジにしておくと、他機種に乗り換えた際(って携帯電話会社の話みたいですが)も使えますがね。
#3. 家の照明のオン・オフをしてみる
残念ながらClova WAVEは手段がないのですが、Amazon EchoとGoogle Homeについては外部サービスと連携することができます。
そこで、ラズベリーパイをベースに赤外線リモコンを制御するサービスを作成し、スマートスピーカーと連携させてみます。お手軽にやるなら、IRKitやその後継のNature Remo、i-RemoconをIFTTT連携させるのをお勧めします。
##3.1 全体構成
そして、以下の3つの仕組みを作る必要があります。
番号 | 処理概要 | 利用するプラットフォーム等 |
---|---|---|
① | スマートスピーカーからの入力を処理し、外部サービスを呼び出す | Alexa/Lambda または Action on Google/Dialogflow |
② | スマートスピーカーからの外部サービス呼び出しをラズベリーパイへ伝える | ngrok, AWS IoT, Beebotte |
③ | 呼び出されたサービスを実行して照明を赤外線で制御する | Node.js(Express) |
##3.2 ラズベリーパイで照明をON/OFFするサービスを作る
まず最初に③のサービスを作ります。赤外線制御用のデバイスとしては、「Raspberry Piではじめるおうちハック」で扱ったirMagicianを使用します。
今回、Node.jsは8.9.1を使用しました。サービスはNode.js上でExpressを使用して作成します。
###3.2.1. irMagicianをNode.jsで制御する
irMagicianはNode.jsのモジュールirmagician使用します。モジュールの使い方はモジュール作者さんのサイトがありますので、そちらを参考に。
照明のONとOFFの赤外線コマンド情報をそれぞれlight_on.json
とlight_off.json
に保存しておきます。そして、これらを使ってオンとオフを実行するためのコードlightModule.js
を作成します。light_on.json
とlight_off.json
はlightModule.js
を同じディレクトリに置いておきます。
'use strict'
exports.on = function(){
var irmagician = require('irmagician');
irmagician.write('light_on.json');
console.log("light on");
setTimeout( function(){
irmagician.play();
}, 2000);
}
exports.off = function(){
var irmagician = require('irmagician');
irmagician.write('light_off.json');
console.log("light off");
setTimeout( function(){
irmagician.play();
}, 2000);
}
これを必要なところで
var light = require('lightModule.js')
light.on();
というように呼び出します。
###3.2.2. Expressを準備する
Expressは、Node.js向けのWeb アプリケーション・フレームワークです。簡単にWebサービスを作成して実行することができます。
まずはExpressをグローバルにインストールします。
npm install express-generator -g
その後、
express --view=pug kadenCtrl
とするとkadenCtrl
ディレクトリが作成され、その配下に必要なファイル一式が生成されます。--view=pug
はつけなくても今回の範囲では問題ありませんが、つけないと実行時にワーニング(JadeはPugになったので云々)が出ます。
cd kadenCtrl
npm install
で準備ができました。次に、公開するサービスのコードを書きます。
###3.2.3. サービスを作成して公開する
次に、呼び出すサービスのパス(URL)を決定します。
Amazon Echo(スマートホームスキルAPI使用)の場合とGoogle Homeの場合では、呼び出しの粒度に若干の差がでてきます。
例えば、「XXXをつけて」という命令を出したとします。XXXが操作対象物(今回は照明機器)の名称です。
詳細は後述しますが、Alexa(Amazon Eco)でスマートホームスキルAPIを使う場合、AlexaからはXXXについてTurnOn
というメッセージがLambda側に通知されます。一方、Google Homeの場合は、Dialogflowにはそのまま「XXXをつけて」というメッセージが通知され、Dialogflowで"XXX"と"つける"に分割してそれぞれをラズベリーパイ側にパラメータとして送信することができます。
このイメージを、照明機器をlight
、制御サービスをKadenCtrl
として、そのままでパス(URL)を決めるとするとして、以下の2つを用意しました。
対象 | パラメータ1 | パラメータ2 | パス(URL) | メソッド | メッセージタイプ | 呼び出し元 |
---|---|---|---|---|---|---|
light | on/off | - | /lights | GET | - | Lambda |
KadenCtrl | light | on/off | /kadenCtrl | POST | JSON | Dialogflow |
Lambda側からの呼び出しは自作するのでどのようにもできるのですが、Google HomeがPOSTなので、対抗してGETにしてみました。RESTに従うなら、両方ともPUTになるとは思うのですが。
この2つのパス(URL)に対応したサービスの実装を行います。KadenCtrl
ディレクトリ配下のroutes
ディレクトリに移動します。
ここに、/light
の処理を実装するlight.js
と/kadenCtrl
を実装するkadenCtrl.js
を作成します。先ほど作成した、lightModule.js等のファイルはKadenCtrl
ディレクトリ配下に配置します。
var express = require('express');
var router = express.Router();
router.get('/', function(req, res, next) {
var act = req.query.action;
if(act == "on"){
var light = require('../lightModule.js');
light.on();
} else {
var light = require('../lightModule.js');
light.off();
}
var ret = {"num" : 1, "location" : "Living room"};
res.header('Content-Type', 'application/json; charset=utf-8');
res.send(ret);
});
module.exports = router;
var express = require('express');
var router = express.Router();
router.post('/', function(req, res, next) {
var ret = "";
if(req.body.result.parameters["KadenCtrl"] == "照明"){
if(req.body.result.parameters["KadenCtrl1"] == "つけて"){
var light = require('../lightModule.js')
light.on();
ret = {"speech" : "つけたよ", "displayText": "つけたよ"};
} else {
var light = require('../lightModule.js')
light.off();
ret = {"speech" : "けしたよ", "displayText": "けしたよ"};
}
}
res.header('Content-Type', 'application/json; charset=utf-8');
res.send(ret);
});
module.exports = router;
つぎに、KadenCtrl
ディレクトリ配下のapp.jsを編集し、先ほど作成したファイルの登録処理
var lights = require('./routes/lights');
var kadenCtrl = require('./routes/kadenCtrl');
と、そのオブジェクトへのルート設定
app.use('/lights', lights);
app.use('/kadenCtrl', kadenCtrl);
を追加します。結果、以下のようになります。
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var index = require('./routes/index');
var users = require('./routes/users');
var lights = require('./routes/lights');
var kadenCtrl = require('./routes/kadenCtrl');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', index);
app.use('/users', users);
app.use('/lights', lights);
app.use('/kadenCtrl', kadenCtrl);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
そして、KadenCtrlディレクトリ配下に移動して、
npm start
と入力します。
pi@raspberry:~/kadenCtrl $ npm start
> kadenctrl@0.0.0 start /home/pi/kadenCtrl
> node ./bin/www
と表示されれば、サービスが正常に起動されています。
##3.3. 外部サービス呼び出しをラズベリーパイへ伝える
次に②ですが、このラズベリーパイのサービスを外部から呼び出すのが一番やっかいです。が、これを簡単に行うことができるサービスがngrokです。
###3.3.1. ngrokのインストール
インストールは簡単。downloadページからLinux ARMをダウンロードします。ターミナルから入手するなら、wgetをインストールした後に、
sudo apt install wget
wgetを使って直接取得します。URLは変更になるかもしれないのでdownloadページで確認してください。
wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-arm.zip
その後、zipを解凍します。
unzip ngrok-stable-linux-arm.zip
ngrok
という実行ファイルができていれば完了です。
###3.3.2. ngrokでサービスを公開する
Expressの3000ポートを公開します。
$ ./ngrok http 3000
と入力すると、以下のようにサービスへのアクセスURLが画面に表示されます。
このURLを呼び出し元に設定すればよいです。Google Homeの場合、呼び出し元として使用するDialogflowのWebhookでは、Google Assistantと連携する場合にはhttpsしか指定できません。また、メソッドはPOSTになります。
無料版では一時的なURL(終了すると無効になって次回は別のものが割り当てられる)ですが、有料版(5$/monthから)ではngrok.ioのサブドメンを使用することができます。
注意しなければならないのはセキュリティ。家の中にあるラブベリーパイのサービスを簡単に呼び出せるということは、イコール他人にも簡単に呼び出せるということです(URLを知っていればですが)。無料でもBasic認証が利用できますので、テストや検証で一時的に使う時以外は設定しましょう。ただし、Basic認証は盗聴や改竄が簡単な部類ですので、最終的には有料のTLSを活用するとか、他のセキュリティを確保した方法を採用した方がよいかと思います。私はここにMQTT(AWS IoTもしくはBeebotte)を使用することを考えています。とはいえ、注意するのがこの公開ポート1つだけでよいというのはお手軽ですね。
##3.4. スマートスピーカーからの入力を処理して外部サービスを呼び出す
最後に①の、スマートスピーカーからの入力を処理して外部サービスを呼び出す部分を作ります。当然ながら、これはAmazon EchoとGoogle Homeで使用する開発プラットフォームが異なります。
まだまだ勉強中ですが、現状での理解をざっくり図にすると以下のような感じでしょうか。
Alexaは使用するスキルの種類でLambdaとの境界線が変わります。カスタムスキル(カスタム対話モデル)を使用すると、会話制御の多くをLambdaで作る必要がありますが、カスタマイズができます。一方、スマートホームスキルAPIを使うと、会話制御をAlexa側でやってくれる感じです。今回は、スマートホームスキルAPIを使用しました。
また、Amazon EchoではAlexaにデバイス管理の機能もあり、そのためのデバイス発見の対応処理がLambda側に必要になります。
Google Homeのケースでは、Google Assistant SDKの機能を使うのですが、その多くをAction on Googleがラップしてくれる感じかと思います。そのため、Google Assistant SDKを直接さわることはありませんでした。
###3.4.1. Amazon Echoの場合
まず、Login with Amazonにログインします。
ここで、OAuth2のクレデンシャルを作成します。
項目 | 内容 |
---|---|
Security Profile Name | プロファイル名。例えばAlexaKadenとか。 |
Security Profile Description | プロファイルの説明。今回は適当で大丈夫。 |
Consent Privacy Notice URL | 今回は適当で大丈夫。http://www.example.com/privacy.html とか。 |
入力後にSaveすると、
Client ID
とClient Secret
が作成されるのでメモっておく。
ここからは、公式のスマートホームスキルの作成手順を参考にしてますので、詳細はそちらを参照ください。
Alexa skills kit(ASK)にログインし、新しいスキルを作成します。
項目 | 内容 |
---|---|
スキルの種類 | スマートホームスキルAPI |
言語 | Japanese |
スキル名 | KadenCtrlとしました |
ペイロードのバージョン | v3 |
以上を入力して保存すると、
アプリケーションID
が作成されるのでメモっておく。次を選択すると、
対話モデルの設定になるが、スマートホームスキルAPIを選択した場合は何もすることはなく次へ。
ここで、いったんASKをそのまま置いておき、IAMロールの作成とLambdaの設定をします。
まずはIAMロールを作成します。これについては、こちらを参考に作成します。
次に、Lambdaに移動します。まずリージョンを選択します。日本語対応のスキルではOregonリージョンを選択します。次に、新しく関数を作成し、
設計図を選択して、検索欄にhome-skill
と入力して検索すると、alexa-smart-home-skill-adapter
が見つかるので選択します。
項目 | 内容 |
---|---|
名前 | KadenCtrlとしました |
ロール | 既存のロールを選択 |
既存のロース | 先ほど作成したIAMロールを選択 |
アプリケーションID | 先ほどASKで作成したアプリケーションID |
トリガーの有効化 | チェックボックスにチェックをつけて有効にする |
入力後に作成を選択します。
項目 | 内容 |
---|---|
ランタイム | Node.js 6.10 |
と設定します。また、右上にARNが記載されているのでメモっておきます。
次にLambdaのコードを記載します。
Lambdaのコードはこちらを参考に作成しました。
また、サービス呼び出し用に、Node.jsのHTTP APIを使用しました。オフの処理は未実装です。http.get()のURLはngrokで表示されているものに変更してください。
var http = require ('http');
exports.handler = function (request, context) {
if (request.directive.header.namespace === 'Alexa.Discovery' && request.directive.header.name === 'Discover') {
log("DEGUG:", "Discover request", JSON.stringify(request));
handleDiscovery(request, context, "");
}
else if (request.directive.header.namespace === 'Alexa.PowerController') {
if (request.directive.header.name === 'TurnOn' || request.directive.header.name === 'TurnOff') {
log("DEBUG:", "TurnOn or TurnOff Request", JSON.stringify(request));
handlePowerControl(request, context);
}
}
function handleDiscovery(request, context) {
var payload = {
"endpoints":
[
{
"endpointId": "demo_id",
"manufacturerName": "Smart Device Company",
"friendlyName": "Kaden Control",
"description": "Smart Device Switch",
"displayCategories": ["SWITCH"],
"cookie": {
"key1": "arbitrary key/value pairs for skill to reference this endpoint.",
"key2": "There can be multiple entries",
"key3": "but they should only be used for reference purposes.",
"key4": "This is not a suitable place to maintain current endpoint state."
},
"capabilities":
[
{
"type": "AlexaInterface",
"interface": "Alexa",
"version": "3"
},
{
"interface": "Alexa.PowerController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"supported": [{
"name": "powerState"
}],
"retrievable": true
}
}
]
}
]
};
var header = request.directive.header;
header.name = "Discover.Response";
log("DEBUG", "Discovery Response: ", JSON.stringify({ header: header, payload: payload }));
context.succeed({ event: { header: header, payload: payload } });
}
function log(message, message1, message2) {
console.log(message + message1 + message2);
}
function handlePowerControl(request, context) {
// get device ID passed in during discovery
var requestMethod = request.directive.header.name;
// get user token pass in request
var powerResult;
if (requestMethod === "TurnOn") {
log("DEBUG", "*** TurnOn2 *** ");
http.get("http://xxxxxxxxx.ngrok.io/lights?action=on", function(res) {
log("DEBUG", "Got response: ", res.statusCode);
res.on("data", function(chunk) {
powerResult = "ON";
doResponse(powerResult)
context.done(null, chunk);
});
}).on('error', function(e) {
context.done('error', e);
});
}
else if (requestMethod === "TurnOff") {
log("DEBUG", "*** TurnOff2 *** ");
// TO DO
powerResult = "OFF";
doResponse(powerResult)
}
}
function doResponse(powerResult){
var contextResult = {
"properties": [{
"namespace": "Alexa.PowerController",
"name": "powerState",
"value": powerResult,
"timeOfSample": "2017-09-03T16:20:50.52Z", //retrieve from result.
"uncertaintyInMilliseconds": 500
}]
};
var responseHeader = request.directive.header;
responseHeader.namespace = "Alexa";
responseHeader.name = "Response";
responseHeader.messageId = responseHeader.messageId + "-R";
var response = {
context: contextResult,
event: {
header: responseHeader
},
payload: {}
};
log("DEBUG", "Alexa.PowerController ", JSON.stringify(response));
context.succeed(response);
}
};
handleDiscovery()は機器発見時に呼ばれます。ここにあるJSONのfriendlyName
キーに指定したものは呼び出す際の名前になるようです。音声入力があった際の処理をhandlePowerControl()で行っています。
ここでまた、ASKに戻ります。
項目 | 内容 |
---|---|
デフォルト | 先ほどLambdaで作成した関数のARN |
エンドポイントの地理的リージョンを設定しますか? | いいえ |
認証URL | Login with Amazonの場合は、https://www.amazon.com/ap/oa?redirect_uri=... redirect_uriはリダイレクトURLの項目に書かれているものを指定 |
クライアント ID | Login with Amazonで作成したClient ID |
スコープ | profile |
項目 | 内容 |
---|---|
認可の承諾タイプ | Auth Code Grantを選択 |
アクセストークンURL | https://api.amazon.com/auth/o2/token |
クライアントシークレット | Login with Amazonで作成したClient Secret |
クライアント認証スキーム | HTTPベーシック認証(推奨) |
ユーザーにリソースと機能へのアクセス権限をリクエストする | チェックをつけない |
入力して次へ。これで完成です。
Alexaに「Kaden Controlをつけて」というと、ラズベリーパイの照明ON処理が動きます。Alexaからは「はい」という応答がかえってきます。
###3.4.2. Google Homeの場合
まず、Action on Googleにログインします。
次に、"Add/import project"を選択すると、プロジェクト作成ダイアログが出るので、プロジェクト名とリージョンを入力します。
次に、Dialogflow(のBUILDボタン)を選択します。
すると、以下のダイアログが表示されますので、
「CREATE ACTIONS ON DIALOGFLOW」を選択すると以下の画面になります。
言語を日本語にしてCREATEを作成します。
次に、Entites
とIntents
を設定します。
まずはEntites
を設定します。認識させたい単語を記載します。Define synonyms
にチェックを入れると、
同義語(synonym)を登録することができます。今回は、「照明」「つけて」「けして」を登録しました。
次にIntents
を設定します。
Actionで設定した内容がパラメータとして付与されます。
次に、Fulfillment
を選択し、Webhookを有効にして、URLにngrokで表示されたもの+"/kadenCtrl"を記載します。httpsを指定する必要があることに注意ください。
再度、Intents
に戻ってUse webgook
にチェックを入れます。
その後、Integrations
に移動してGoogle Assistant
を選ぶと以下のダイアログが出ます。
ここのAdditional triggering intents
にて先ほどのintents
を選択します。
これで完成です。
Google Homeに
私:「OK, Google. テスト用アプリにつないで。」
と言うと
Google Home:「こんにちは」
という返答が返ればサービスが開始されてます。
その後は、
私:「電気けして」
Google Home:「けしたよ」
私:「電気をつけて」
Google Home:「つけたよ」
という感じで制御できます。
サービスを終了するには、
私:「おしまい」
Google Home:「すいません、おてつだいできません」
とう感じで終了になります。
サービス名(アプリ名)の「テスト用アプリ」はAction on Googleの設定で変更できます。
#4. 最後に
今回、Amazon EchoとGoogle Homeの両方から、ラズベリーパイのサービスを呼び出してみました。
基本、どちらも同じようなサービスを作ることができると思いますが、実現の仕組みの違いから、サービス形態によっては作りやすい・作りにくいがでる気がします。
当然ながら、Amazon EchoはAWSのサービスをフルに活用できるのが魅力です。
一方、Google HomeはDialoflowの力でわりと簡単に会話処理ができますし、ラズベリーパイ(サービス処理側)にそのままパラメータとして内容を送ることができるのが魅力です。
ただし、Google Homeはサービスの開始に「XXXにつないで」と1ステップ入るので、一言で指示するようなサービス形式には向いてない気がします。もしかしたらやり方があるのかもしれませんが。
どちらにせよ、音声認識・合成を非常に簡単に使うことができるのは素晴らしいです。今後、どんどん活用していきたいと感じました。
(おしまい)