知的好奇心 for IoT

IoT関連の知的好奇心を探求するブログです

ツンデレキャラでWiFi温湿度計(ESP8266 + Blynk)の値を教えてくれるAlexaスキルを作ってみた

AlexaやGoogle Asistantに指示を出して機器をコントロールすることは行いましたので、今度は機器から情報を受け取って音声アシスタントから教えてもらうことに挑戦しました。

 

始めにIFTTTや何か簡単に行えるサービスが無いか調べて見たのですが、意外なことに全然見つからなかったので、諦めてオフィシャルなものでやることにしました。

個人的にはGoogle Assistantの方が簡単にできそうな印象を持っていたのですが、これが全然わからず、Alexaだとスキルを作ればできそうだったので初めてのAlexaスキル開発に挑戦することになりました。

 

参考にした記事はこれです。

 

上の記事を参考に、今回作ったAlexaスキルの動作フローはこんな感じです。

f:id:IntellectualCuriosity:20180112184236p:plain

ブログのサイドバーに部屋とベランダの温度・湿度・気圧を表示しているのと同じように、Lambda FunctionからBlynkのREST APIをたたいて情報を取得しています。

 

で、これが作ったAlexaスキルで部屋とベランダに設置しているWiFi温湿度計(ESP8266 + Blynk)の値を教えてもらうデモビデオです。

実際、温度や湿度などという情報は見た方が早く、いちいちAlexaに聞く方がとんでもなく手間がかかって実用的じゃないと感じていたため、それならということで、Alexaをなかなか素直に教えてくれないツンデレキャラにしてしまいました...。

 

使ったもの(必要なもの)

  • Echosim.io (Alexa Skill Testing Tool)
    Amazonのアカウントが必要(amazon.comの方)。日本語に対応していました。
    お金のある人は普通にAmazon Echoを買えばいい思う。
  • Alexa Skills Kit
    上のリンクのページで青い「スキル開発を始める」をクリックしましょう!
    Amazon Developerアカウントが必要。これも日本語になってます。
  • AWS Lambda 
    AWSアカウントが必要。クレジットカードの登録も必要でした。
    大丈夫!これも日本語対応しています!
  • amazon alexa
    alexa.amazon.comの方。alexa.amazon.co.jpだとEchosim.ioが使えないみたい。
    Amazonのアカウントが必要(amazon.comの方)。でも、日本語に対応してます!

 

Alexa Skills Kitの設定

スキル情報

f:id:IntellectualCuriosity:20180109235346p:plain

対話モデル

インテントスキーマ

{
  "intents": [
    {
      "slots": [
        {
          "name": "Location",
          "type": "LIST_OF_LOCATIONS"
        },
        {
          "name": "Measurement",
          "type": "LIST_OF_MEASUREMENTS"
        }
      ],
      "intent": "GetMeasurement"
    }
  ]
}

f:id:IntellectualCuriosity:20180110000120p:plain

※サンプルスロットは列挙ではないとマニュアルに書いてありました。つまり設定した値はあくまで例であって、Alexaの学習能力(気分次第)で他の値も有効になるそうです。

f:id:IntellectualCuriosity:20180110000739p:plain

設定

f:id:IntellectualCuriosity:20180110001625p:plain

「カスタムスキルのAWS Lambda関数を作成する」リンクから作成したLambda関数のARNをエンドポイントに設定します。

 

Lambda関数の作成

「カスタムスキルのAWS Lambda関数を作成する」リンクから表示されたページの「AWS Lambda」をクリックします。

f:id:IntellectualCuriosity:20180111204923p:plain

AWS Lambdaのページが表示されるので、アカウントの作成からアカウントを作ります。アカウントを作ったら「AWS Lamdaの使用開始」をクリックします。

f:id:IntellectualCuriosity:20180111205227p:plain

右上のリージョンを「アジアパシフィック(東京)」に変えます。

※Lambda関数はリージョンごとにホストされるようになっているので注意しましょう!

リージョンを変更したらオレンジの「関数の作成」をクリックします。

f:id:IntellectualCuriosity:20180111205909p:plain

ぼくが参考にしたサイトでは、テンプレートをダウンロードしてから「一から作成」を選んで関数を作っていたので、ぼくもそれに倣ってみました。

「設計図」を選んでからフィルターに「alexa」と入力すると6つのテンプレートが表示されます。「alexa-skills-kit-color-expert」を選んでから「エクスポート」をクリックしてダウンロードします。

f:id:IntellectualCuriosity:20180111211702p:plain

ダウンロード後、「一から作成」を選びます。

名前:readTemperatureHumidity

ランタイム:Node.js 6.10

ロール:最初は「カスタムロールの作成」を選びます

f:id:IntellectualCuriosity:20180111230232p:plain

ロールで「カスタムロールの作成」を選ぶとウインドウが開きます。

「新しいIAMロールの作成」を選択して適当なロール名を付けて(勝手に付いたかも)「許可」をクリックします。

f:id:IntellectualCuriosity:20180111234356p:plain

関数の作成を続けます。

名前:readTemperatureHumidity

ランタイム:Node.js 6.10

ロール:既存のロールを選択

既存のロール:lambda_basic_execution (さっき作ったロール)

f:id:IntellectualCuriosity:20180111234914p:plain

リージョンが「東京」になっているのを確認して、トリガーに「Alexa Skills Kit」を選びます。

f:id:IntellectualCuriosity:20180110022019p:plain

次に真ん中の「readTemperatureHumidity」クリックして、コードを記述します。

関数コード (Node.js 6.10)

'use strict';

/**
 * This sample demonstrates a simple skill built with the Amazon Alexa Skills Kit.
 * The Intent Schema, Custom Slots, and Sample Utterances for this skill, as well as
 * testing instructions are located at http://amzn.to/1LzFrj6
 *
 * For additional samples, visit the Alexa Skills Kit Getting Started guide at
 * http://amzn.to/1LGWsLG
 */

var http = require('http');

// --------------- Helpers that build all of the responses -----------------------

function buildSpeechletResponse(title, output, repromptText, shouldEndSession) {
    return {
        outputSpeech: {
            type: 'PlainText',
            text: output,
        },
        card: {
            type: 'Simple',
            title: "SessionSpeechlet - " + title,
            content: "SessionSpeechlet - " + output,
        },
        reprompt: {
            outputSpeech: {
                type: 'PlainText',
                text: repromptText,
            },
        },
        shouldEndSession: shouldEndSession
    };
}

function buildResponse(sessionAttributes, speechletResponse) {
    return {
        version: '1.0',
        sessionAttributes,
        response: speechletResponse,
    };
}


// --------------- Functions that control the skill's behavior -----------------------

function getWelcomeResponse(callback) {
    // If we wanted to initialize the session to have some attributes we could add those here.
    const sessionAttributes = {};
    const cardTitle = 'Welcome';
    const speechOutput = "温湿度計よっ!願い事を言いなさいっ!";
    // If the user either does not reply to the welcome message or says something that is not
    // understood, they will be prompted again with this text.
    const repromptText = "温湿度計よっ!願い事を言いなさいっ!";
    const shouldEndSession = false;

    callback(sessionAttributes,
        buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

function handleSessionEndRequest(callback) {
    const cardTitle = 'Session Ended';
    const speechOutput = 'Thank you for trying the Alexa Skills Kit sample. Have a nice day!';
    // Setting this to true ends the session and exits the skill.
    const shouldEndSession = true;

    callback({}, buildSpeechletResponse(cardTitle, speechOutput, null, shouldEndSession));
}

function createLocationAttributes(location) {
    return {
        location: location
    };
}

/**
 * Read temperature in the session and prepares the speech to reply to the user.
 */
function readTemperatureInSession(intent, session, callback) {
    const cardTitle = intent.name;
    const LocationSlot = intent.slots.Location;
    const MeasurementSlot = intent.slots.Measurement;
    let repromptText = '';
    let sessionAttributes = {};
    const shouldEndSession = false;
    let speechOutput = '';
	var body = '';
	var blynkAuthToken;
	var blynkPin;

    var location = LocationSlot.value;
    if (location == '' && session.attributes) {
        location = session.attributes.location;
    }
    if (location === '') {
        location = 'ベランダ';
    }
    switch (location) {
        case '部屋':
            blynkAuthToken = '**********'; // 部屋の温湿度計のtoken
            break;
        case 'ベランダ':
        case '外':
            blynkAuthToken = '**********'; // ベランダの温湿度・気圧計のtoken
            break;
    }

    var measurement = MeasurementSlot.value;
    if (measurement === '') {
        measurement = '温度';
    }
    switch (measurement) {
        case '気温':
        case '温度':
            blynkPin = 'V1';
            break;
        case '湿度':
            blynkPin = 'V2';
            break;
        case '気圧':
            blynkPin = 'V3';
            break;
    }

	var httpPromise = new Promise( function(resolve,reject){
		http.get({
			host: 'blynk-cloud.com',
			path: '/' + blynkAuthToken + '/get/' + blynkPin,
			port: '80'
		}, function(response) {
			// Continuously update stream with data
			response.on('data', function(d) {
				body += d;
			});
			response.on('end', function() {
				// Data reception is done, do whatever with it!
				console.log(body);
				resolve('Done Sending');
			});
		});
	});
	httpPromise.then(
		function(data) {
			console.log('Function called succesfully:', data);
			var info = parseFloat(JSON.parse(body));
			speechOutput = '勘違いしないでよねっ!あなたのために言うんじゃないんだからっ!' + location + 'の ' + measurement + 'は ';
        	repromptText = speechOutput;
			switch (measurement) {
                default:
                case '気温':
                case '温度':
        			speechOutput = speechOutput + info.toFixed(1) + '度 よ!';
		        	repromptText = repromptText + info.toFixed(1) + '℃よ。';
                    break;
                case '湿度':
        			speechOutput = speechOutput + info.toFixed(1) + 'パーセント よ!';
		        	repromptText = repromptText + info.toFixed(1) + '%よ。';
                    break;
                case '気圧':
        			speechOutput = speechOutput + info.toFixed(0) + 'ヘクトパスカル よ!';
		        	repromptText = repromptText + info.toFixed(0) + 'hPaよ。';
                    break;
			}
			console.log(speechOutput);
			sessionAttributes = createLocationAttributes(location);
			callback(sessionAttributes,buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
		},
		function(err) {
			console.log('An error occurred:', err);
		}
	);
}
    
// --------------- Events -----------------------

/**
 * Called when the session starts.
 */
function onSessionStarted(sessionStartedRequest, session) {
    console.log("onSessionStarted requestId=${sessionStartedRequest.requestId}, sessionId=${session.sessionId}");
}

/**
 * Called when the user launches the skill without specifying what they want.
 */
function onLaunch(launchRequest, session, callback) {
    console.log("onLaunch requestId=${launchRequest.requestId}, sessionId=${session.sessionId}");

    // Dispatch to your skill's launch.
    getWelcomeResponse(callback);
}

/**
 * Called when the user specifies an intent for this skill.
 */
function onIntent(intentRequest, session, callback) {
    console.log("onIntent requestId=${intentRequest.requestId}, sessionId=${session.sessionId}");

    const intent = intentRequest.intent;
    const intentName = intentRequest.intent.name;

    // Dispatch to your skill's intent handlers
    if (intentName === 'GetMeasurement') {
        readTemperatureInSession(intent, session, callback);
    } else if (intentName === 'AMAZON.HelpIntent') {
        getWelcomeResponse(callback);
    } else if (intentName === 'AMAZON.StopIntent' || intentName === 'AMAZON.CancelIntent') {
        handleSessionEndRequest(callback);
    } else {
        throw new Error('Invalid intent');
    }
}

/**
 * Called when the user ends the session.
 * Is not called when the skill returns shouldEndSession=true.
 */
function onSessionEnded(sessionEndedRequest, session) {
    console.log("onSessionEnded requestId=${sessionEndedRequest.requestId}, sessionId=${session.sessionId}");
    // Add cleanup logic here
}


// --------------- Main handler -----------------------

// Route the incoming request based on type (LaunchRequest, IntentRequest,
// etc.) The JSON body of the request is provided in the event parameter.
exports.handler = (event, context) => {
    try {
        console.log("event.session.application.applicationId=${event.session.application.applicationId}");

        /**
         * Uncomment this if statement and populate with your skill's application ID to
         * prevent someone else from configuring a skill that sends requests to this function.
         */
        /*
        if (event.session.application.applicationId !== 'amzn1.echo-sdk-ams.app.[unique-value-here]') {
             context.fail("Invalid Application ID");
        }
        */

        if (event.session.new) {
            onSessionStarted({ requestId: event.request.requestId }, event.session);
        }

        if (event.request.type === 'LaunchRequest') {
            onLaunch(event.request,
                event.session,
                function callback(sessionAttributes, speechletResponse) {
					context.succeed(buildResponse(sessionAttributes, speechletResponse));
				});
        } else if (event.request.type === 'IntentRequest') {
            onIntent(event.request,
                event.session,
                function callback(sessionAttributes, speechletResponse) {
					context.succeed(buildResponse(sessionAttributes, speechletResponse));
				});
        } else if (event.request.type === 'SessionEndedRequest') {
            onSessionEnded(event.request, event.session);
            context.succeed();
        }
    } catch (e) {
        context.fail("Exception: " + e);
    }
};

 

Alexa Skills Kitでテスト

設定メニューのLambda関数のarnを記入して「次へ」をクリックするとテストが行えます。

f:id:IntellectualCuriosity:20180123232755p:plain

f:id:IntellectualCuriosity:20180123233026p:plain

ここでは、既にブリンクに繋がっている状態からの会話になるようで、「ブリンクを開いて」とかは言う必要がないようです。

ここで正常に動いていれば完成でーす!パチパチ!

ただ、ここで少し問題というか注意が。

この状態のAlexaスキルは”公開されていない”状態の”DEVスキル”という扱いになっていて、amazon開発者コンソール・alexa.amazon.com・alexa.amazon.co.jpを同じアカウント(メールアドレス)で登録しているとalexa.amazon.comにしか”DEVスキル”として出てこないんです!

Echoとかで実際に動かすときは、この辺りのことをよく把握していないと「あれー、うごかなーい♡」みたいなことになると思います。

f:id:IntellectualCuriosity:20180123234109p:plain

いつか、このアカウントの問題がAmazon帝国の世界制覇に重大な問題をもたらす気がするのは僕だけでしょうか...。

 

おわり