28
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

いろんなスマートスピーカーを使ってみた

Last updated at Posted at 2017-12-15

この記事は、おうちハック 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 Google Action on Google, Dialogflow なし
image.png

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 Remoi-RemoconをIFTTT連携させるのをお勧めします。

##3.1 全体構成

まず、全体のシステム構成は以下になります。
system.jpg

そして、以下の3つの仕組みを作る必要があります。

system2.jpg

番号 処理概要 利用するプラットフォーム等
スマートスピーカーからの入力を処理し、外部サービスを呼び出す Alexa/Lambda または Action on Google/Dialogflow
スマートスピーカーからの外部サービス呼び出しをラズベリーパイへ伝える ngrok, AWS IoT, Beebotte
呼び出されたサービスを実行して照明を赤外線で制御する Node.js(Express)

##3.2 ラズベリーパイで照明をON/OFFするサービスを作る
まず最初に③のサービスを作ります。赤外線制御用のデバイスとしては、「Raspberry Piではじめるおうちハック」で扱ったirMagicianを使用します。
image.png

今回、Node.jsは8.9.1を使用しました。サービスはNode.js上でExpressを使用して作成します。

###3.2.1. irMagicianをNode.jsで制御する

irMagicianはNode.jsのモジュールirmagician使用します。モジュールの使い方はモジュール作者さんのサイトがありますので、そちらを参考に。

照明のONとOFFの赤外線コマンド情報をそれぞれlight_on.jsonlight_off.jsonに保存しておきます。そして、これらを使ってオンとオフを実行するためのコードlightModule.jsを作成します。light_on.jsonlight_off.jsonlightModule.jsを同じディレクトリに置いておきます。

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ディレクトリ配下に配置します。

light.js
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;
kadenCtrl.js
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);

を追加します。結果、以下のようになります。

app.js
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が画面に表示されます。

image.png

この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で使用する開発プラットフォームが異なります。

まだまだ勉強中ですが、現状での理解をざっくり図にすると以下のような感じでしょうか。
middle.jpg

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にログインします。

image.png

ここで、OAuth2のクレデンシャルを作成します。

image.png

項目 内容
Security Profile Name プロファイル名。例えばAlexaKadenとか。
Security Profile Description プロファイルの説明。今回は適当で大丈夫。
Consent Privacy Notice URL 今回は適当で大丈夫。http://www.example.com/privacy.html とか。

入力後にSaveすると、

image.png

Client IDClient Secretが作成されるのでメモっておく。

ここからは、公式のスマートホームスキルの作成手順を参考にしてますので、詳細はそちらを参照ください。

Alexa skills kit(ASK)にログインし、新しいスキルを作成します。

image.png

項目 内容
スキルの種類 スマートホームスキルAPI
言語 Japanese
スキル名 KadenCtrlとしました
ペイロードのバージョン v3

以上を入力して保存すると、

image.png

アプリケーションIDが作成されるのでメモっておく。次を選択すると、

image.png

対話モデルの設定になるが、スマートホームスキルAPIを選択した場合は何もすることはなく次へ。

ここで、いったんASKをそのまま置いておき、IAMロールの作成とLambdaの設定をします。

まずはIAMロールを作成します。これについては、こちらを参考に作成します。

次に、Lambdaに移動します。まずリージョンを選択します。日本語対応のスキルではOregonリージョンを選択します。次に、新しく関数を作成し、

image.png

設計図を選択して、検索欄にhome-skillと入力して検索すると、alexa-smart-home-skill-adapterが見つかるので選択します。

image.png

項目 内容
名前 KadenCtrlとしました
ロール 既存のロールを選択
既存のロース 先ほど作成したIAMロールを選択
アプリケーションID 先ほどASKで作成したアプリケーションID
トリガーの有効化 チェックボックスにチェックをつけて有効にする

入力後に作成を選択します。

image.png

項目 内容
ランタイム 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に戻ります。

image.png

項目 内容
デフォルト 先ほどLambdaで作成した関数のARN
エンドポイントの地理的リージョンを設定しますか? いいえ
認証URL Login with Amazonの場合は、https://www.amazon.com/ap/oa?redirect_uri=... redirect_uriはリダイレクトURLの項目に書かれているものを指定
クライアント ID Login with Amazonで作成したClient ID
スコープ profile

image.png

項目 内容
認可の承諾タイプ 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にログインします。
image.png

次に、"Add/import project"を選択すると、プロジェクト作成ダイアログが出るので、プロジェクト名とリージョンを入力します。
image.png

次に、Dialogflow(のBUILDボタン)を選択します。

image.png

すると、以下のダイアログが表示されますので、

image.png

「CREATE ACTIONS ON DIALOGFLOW」を選択すると以下の画面になります。

image.png

言語を日本語にしてCREATEを作成します。

次に、EntitesIntentsを設定します。

まずはEntitesを設定します。認識させたい単語を記載します。Define synonymsにチェックを入れると、
同義語(synonym)を登録することができます。今回は、「照明」「つけて」「けして」を登録しました。

image.png

次にIntentsを設定します。

image.png

Actionで設定した内容がパラメータとして付与されます。

次に、Fulfillmentを選択し、Webhookを有効にして、URLにngrokで表示されたもの+"/kadenCtrl"を記載します。httpsを指定する必要があることに注意ください。

image.png

再度、Intentsに戻ってUse webgookにチェックを入れます。

image.png

その後、Integrationsに移動してGoogle Assistantを選ぶと以下のダイアログが出ます。

image.png

ここのAdditional triggering intentsにて先ほどのintentsを選択します。

image.png

これで完成です。

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ステップ入るので、一言で指示するようなサービス形式には向いてない気がします。もしかしたらやり方があるのかもしれませんが。

どちらにせよ、音声認識・合成を非常に簡単に使うことができるのは素晴らしいです。今後、どんどん活用していきたいと感じました。

(おしまい)

28
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?