LoginSignup
4
2

More than 3 years have passed since last update.

ESP32をAlexa Gadgets Toolkitデバイスにしよう:カスタムスキル→Alexa Gadgetデバイスへの通知

Last updated at Posted at 2020-07-19

今回は、ESP32をAlexa Gadgets Toolkitデバイスにしよう に続いて、カスタムインタフェースに対応させます。

カスタムインタフェースは、AlexaのスキルからAlexa Gadgetsデバイスに指示を出したり、Alexa Gadgetsからスキルにイベントを伝達できたりするためのものです。

今回は、Echoデバイスへの発話を契機に、Alexa Gadgetデバイスに文字列付きで通知します。また、Alexa GadgetデバイスのM5StickCについているボタンの押下を契機に、メッセージ付きでAlexaスキルに通知します。
と言いながら、体力が尽きたので、とりあえず、前者です。

image.png

以下に記載されている通りに進めます。
 https://developer.amazon.com/ja-JP/docs/alexa/alexa-gadgets-toolkit/custom-interface.html

(GitHubへの反映は次回記事で。。。)

カスタムスキルの作成

いつもの通り、alexa developer consoleで作成します。

alexa developer console
 https://developer.amazon.com/alexa/console/ask

(ASK CLI:Alexa Skills Kit コマンドラインインタフェース を使ったやり方もあるのですが、わかりにくくて遅いのでやめておきます)

image.png

「スキルの作成」ボタンを押下し、適当なスキル名を入力、モデルはカスタム、ホスティング方法はAlexa-Hosted(Node.js) を選択します。

image.png

今回はスキル名を「テストスキル」としました。

image.png

次に、左側のナビゲータからインターフェースを選択し、カスタムインタフェースコントローラーをEnableにして、「インタフェースを保存」ボタンを押下します。

image.png

それから、左側のナビゲータからエンドポイントを選択して、サービスのエンドポイントの種類としてAWS LambdaのARNを選択すると表示されえるスキルIDをメモっておきます。

image.png

次に、これと連携するLambdaを作成します。
まずは普通にLambdaの関数を作成します。関数名は適当に「test-alexa」とでもしておきます。ランタイムは、Node.js 12.xを選択しました。

そして、Lambdaの関数のデザイナのところから「+トリガーを追加」を押下し、Alexa Skills Kitからのトリガを作成します。

image.png

ここでさきほどメモったスキルIDを入力します。
今度は、右上に表示されるLambdaのARNをメモっておきます。

image.png

またalexa developer consoleのエンドポイントのところに戻ってきて、デフォルト地域(必須)と極東(オプション)に、さきほどメモったLambdaのARNをコピーします。最後に「エンドポイントを保存」ボタンを押下します。

カスタムスキルからAlexa Gadgetデバイスに通知

まずは簡単な方から。カスタムスキルからAlexa Gadgetデバイスに通知します。
通知するタイミングは、まずはこのテストスキルが起動されたときとしましょう。

どこかにLambdaのためのフォルダを作ります。

mkdir test-alexa
cd test-alexa
mkdir lambda
cd lambda
npm init -y
npm install --save ask-sdk-core
npm install --save ask-sdk-model
npm install --save ask-sdk

vi index.js

index.jsはこんな感じです。

index.js
'use strict';

const Alexa = require('ask-sdk-core');

const HELPER_BASE = process.env.HELPER_BASE || './helpers/';
const AskUtils = require(HELPER_BASE + 'alexa-utils');
const app = new AskUtils(Alexa);

app.intent('LaunchRequest', async (handlerInput) =>{
    console.log(handlerInput);

    var endpoints = await app.getConnectedEndpoints(handlerInput);
    if (endpoints.length == 0) {
        console.error("No endpoints");
        var builder = handlerInput.responseBuilder;
        builder.speak('エンドポイントを取得できませんでした。');
        return builder.getResponse();
    }

    var sessionAttributes = app.getAttributes(handlerInput);
    sessionAttributes.endpointId = endpoints[0].endpointId;
    app.setAttributes(handlerInput, sessionAttributes)

    var endpointId = app.getAttributes(handlerInput).endpointId;

    var payload = {
        message: "Hello"
    };

    var builder = handlerInput.responseBuilder;
    builder.speak('こんにちは');
    builder.reprompt('どんな御用ですか');
    builder.addDirective(app.buildCustomDirective(endpointId, 'Custom.Sample', 'Button', payload));
    return builder.getResponse();
});

exports.handler = app.lambda();

ユーティリティも作っておきました。(Action on Google っぽくしています)

mkdir helpers
cd helpers
vi alexa-utils.js

helpers/alexa-utils.js
'use strict';

var AWS = require('aws-sdk');
AWS.config.update({region: 'ap-northeast-1'});

//const Adapter = require('ask-sdk-dynamodb-persistence-adapter');
//const config = {tableName: 'AskPersistentAttributes', createTable: true};
//var adapter = new Adapter.DynamoDbPersistenceAdapter(config);   

class AlexaUtils{
    constructor(alexa, adapter){
        this.alexa = alexa;
        this.skillBuilder = alexa.SkillBuilders.custom();
        this.DynamoDBAdapter = adapter;        
    }

    intent( matcher, handle ){
        this.skillBuilder.addRequestHandlers(new BaseIntentHandler(matcher, handle));
    }

    customReceived( handle ){
        this.skillBuilder.addRequestHandlers(new BaseIntentHandler("CustomInterfaceController.EventsReceived", handle));
    }
    customExpired( handle ){
        this.skillBuilder.addRequestHandlers(new BaseIntentHandler("CustomInterfaceController.Expired", handle));
    }

    errorIntent( handle ){
        ErrorHandler.handle = handle;
    }

    getAttributes( handlerInput ){
        return handlerInput.attributesManager.getSessionAttributes();
    }

    setAttributes( handlerInput, attributes){
        handlerInput.attributesManager.setSessionAttributes(attributes);
    }

    async getPersistentAttributes( handlerInput ){
        return handlerInput.attributesManager.getPersistentAttributes();
    }

    setPersistentAttributes( handlerInput, attributes){
        handlerInput.attributesManager.setPersistentAttributes(attributes);
    }

    async savePersistentAttributes( handlerInput ){
        handlerInput.attributesManager.savePersistentAttributes();
    }

    getSlotId(slot){
        if( slot.resolutions.resolutionsPerAuthority[0].status.code != "ER_SUCCESS_MATCH" )
            return null;
        return slot.resolutions.resolutionsPerAuthority[0].values[0].value.id;
    }

    getSlots( handlerInput ){
        return handlerInput.requestEnvelope.request.intent.slots;
    }

    getAccessToken(handlerInput){
        return handlerInput.requestEnvelope.context.System.user.accessToken;
    }

    getConnectedEndpoints(handlerInput){
        return handlerInput.serviceClientFactory.getEndpointEnumerationServiceClient().getEndpoints()
        .then(response =>{
            return response.endpoints;
        });
    }

    buildCustomDirective(endpointId, namespace, name, payload) {
        return {
            type: 'CustomInterfaceController.SendDirective',
            header: {
                name: name,
                namespace: namespace
            },
            endpoint: {
                endpointId: endpointId
            },
            payload: payload
        };
    }

    buildStartEventHandlerDirective(token, namespace, name, expirationPayload, durationMs) {
        return {
            type: "CustomInterfaceController.StartEventHandler",
            token: token,
            eventFilter: {
                filterExpression: {
                    'and': [
                        { '==': [{ 'var': 'header.namespace' }, namespace] },
                        { '==': [{ 'var': 'header.name' }, name] }
                    ]
                },
                filterMatchAction: 'SEND_AND_TERMINATE'
            },
            expiration: {
                durationInMilliseconds: durationMs,
                expirationPayload: expirationPayload
            }
        };
    }

    buildStopEventHandlerDirective(token) {
        return {
            type: "CustomInterfaceController.StopEventHandler",
            token: token
        };
    }

    lambda(){
        if( this.DynamoDBAdapter ){
            return this.skillBuilder
            .addErrorHandlers(ErrorHandler)
            .withPersistenceAdapter(this.DynamoDBAdapter)
            .withApiClient(new this.alexa.DefaultApiClient())
            .lambda();
        }else{
            return this.skillBuilder
            .addErrorHandlers(ErrorHandler)
            .withApiClient(new this.alexa.DefaultApiClient())
            .lambda();
        }
    }
};

class BaseIntentHandler{
    constructor(matcher, handle){
        this.matcher = matcher;
        this.myhandle = handle;
    }

    canHandle(handlerInput) {
        if( this.matcher == 'LaunchRequest'){
            return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
        }else if( this.matcher == 'HelpIntent' ){
            return handlerInput.requestEnvelope.request.type === 'IntentRequest'
                && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent';
        }else if( this.matcher == 'CancelIntent' ){
            return handlerInput.requestEnvelope.request.type === 'IntentRequest'
                && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent';            
        }else if( this.matcher == 'StopIntent'){
            return handlerInput.requestEnvelope.request.type === 'IntentRequest'
                && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent';
        }else if( this.matcher == 'SessionEndedRequest'){
            return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest';
        }else if( this.matcher == 'NavigateHomeIntent'){
            return handlerInput.requestEnvelope.request.type === 'IntentRequest'
                && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.NavigateHomeIntent';
        }else if( this.matcher == 'CustomInterfaceController.EventsReceived' ){
            return handlerInput.requestEnvelope.request.type === 'CustomInterfaceController.EventsReceived';
        }else if( this.matcher == 'CustomInterfaceController.Expired' ){
            return handlerInput.requestEnvelope.request.type === 'CustomInterfaceController.Expired';
        }else{
            return handlerInput.requestEnvelope.request.type === 'IntentRequest'
                && handlerInput.requestEnvelope.request.intent.name === this.matcher;
        }
    }

    async handle(handlerInput) {
        console.log('handle: ' + this.matcher + ' called');
        try{
            return await this.myhandle(handlerInput);
        }catch(error){
            console.error(error);
            throw error;
        }
    }
}

const ErrorHandler = {
    canHandle() {
        return true;
    },

    handle(handlerInput, error) {
        console.log(`Error handled: ${error.message}`);
        console.log(`type: ${handlerInput.requestEnvelope.request.type}`);
        return handlerInput.responseBuilder
            .speak('よく聞き取れませんでした。')
            .reprompt('もう一度お願いします。')
            .getResponse();
    },
};

module.exports = AlexaUtils;

大事なのが、以下の部分です。

index.js
    builder.addDirective(app.buildCustomDirective(endpointId, 'Custom.Sample', 'Button', payload));

Custom.Sampleというnamespaceで、Buttonという名前のディレクティブを送っています。適当な名称に決めればよいのですが、Custom. で始めるnamespaceである必要があります。
ここらへんの名前は後で示すAlexa Gadgetデバイス側で合わせる必要がありますので後述します。

Lambdaのデプロイ

test-alexa/lambda のフォルダ一式をZIPに固めて、Lambdaにアップロードします。
あと、環境変数も設定しておいてください。

HELPER_BASE='./helpers/'

これで、準備完了です。

Alexa Gadgetデバイスでの受信の準備

まずは、Custom.Sampleというnamespaceをサポートしていることを表明する必要があります。関数 makeDiscoveryDiscoverEvent() で定義しています。前半は以前と同じですが、3つ目のcapabilitiesが該当します。

以下のように定義します。

main.cpp
  discover_response_envelope.event.payload.endpoints[0].capabilities_count = 3;

  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[0].type, "AlexaInterface");
  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[0].interface, "Notifications");
  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[0].version, "1.0");

  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[1].type, "AlexaInterface");
  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[1].interface, "Alexa.Gadget.StateListener");
  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[1].version, "1.0");
  discover_response_envelope.event.payload.endpoints[0].capabilities[1].configuration.supportedTypes_count = 1;
  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[1].configuration.supportedTypes[0].name, "wakeword");

  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[2].type, "AlexaInterface");
  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[2].interface, "Custom.Sample");
  strcpy(discover_response_envelope.event.payload.endpoints[0].capabilities[2].version, "1.0");

次は、受信したときの処理を実装します。
以下の関数の、

main.cpp
class MyCharacteristicCallbacks : public BLECharacteristicCallbacks{
  void onWrite(BLECharacteristic* pCharacteristic){

以下の辺りです。

main.cpp
      if (0 == strcmp(directive_envelope.directive.header.name, "Button") && (0 == strcmp(directive_envelope.directive.header.namespacc, "Custom.Sample"))){
        pb_istream_t istream_directive = pb_istream_from_buffer(gp_receive_buffer, g_receive_total_len);
        directive_DirectiveParserProto envelope = directive_DirectiveParserProto_init_default; 

        status = pb_decode(&istream_directive, directive_DirectiveParserProto_fields, &envelope);
        if( !status ){
          Serial.println("pb_decode Error");
          return;
        }
        Serial.printf("payload=%s\n", envelope.directive.payload.bytes); 
        DeserializationError err = deserializeJson(json_message, envelope.directive.payload.bytes);
        if( err ){
          Serial.println("Deserialize error");
          Serial.println(err.c_str());
          return;
        }
        const char* message = json_message["message"];
        Serial.println(message);
        M5.Lcd.fillScreen(BLACK);
        M5.Lcd.setTextSize(4);
        M5.Lcd.setCursor(0, 0);
        M5.Lcd.print(message);
      }

見ての通り、namespaceとnameで受信パケットから判別して、プロトコルバッファ解析しています。受信内容はpayloadに示されているのですが、JSONがStringifyされて格納されています。
ですので、ArduinoJsonを使ってデコードしています。

その宣言は以下の通りです。(2つ以内のメンバがある前提にしています)

main.cpp
#include <ArduinoJson.h>

const int message_capacity = JSON_OBJECT_SIZE(2);
StaticJsonDocument<message_capacity> json_message;
char message_buffer[255];

デコードした結果をSerialに出力したり、M5StickCのLCDに表示したりしています。

カスタムスキルの公開

最後にカスタムスキルを公開して、Echoデバイスに登録します。
alexa developer consoleに戻って、公開作業をします。

まずは公開タブを選択します。

image.png

適当に入力してください。。。公開名は適当に「テストスキル」とでもしておきました。
小さなスキルアイコン(108x108)と大きなスキルアイコン(512x512)が必要です。
いろんな作り方がありますが、以下もあります。

https://poruruba.github.io/utilities/
→ ユーティリティから画像ファイルを選択、icon sizeにalexaを選択。
 あとは、適当な画像ファイルをアップロードしてファイルに保存ボタンを押下するだけです。

(参考)
 便利ページ:Javascriptでアイコンファイルを生成する

さらに進んで、

image.png

公開範囲のページが表示されます。ここで、このスキルにアクセスできるユーザとして公開を選択し、ベータテストのところに、メールアドレスを入力します。

image.png

おそらくメールが飛んでいるかと思いますので、それに従ってEchoデバイスにテストスキルを登録します。

以下を参考にしてください。
 SwaggerでLambdaのデバッグ環境を作る(4):Alexaをデバッグする

※メール内のリンクからAlexaアプリを開くときには、メーラーのブラウザではなく、外部のChromeブラウザで開くようにしてください。

動作を確認する

それでは、さっそく動作を確認してみましょう。
Echoデバイスに対して、以下を言います。
「アレクサ、テストアプリにつないで」
そうすると、
「こんにちは」
と答えてくれるはずです。Lambdaでそう実装したので。
それと同時に、Alexa Gadgetデバイスにメッセージが届いているかと思います。それが成功です。
終わったら、
「おわり」
といって、テストスキルを閉じておきましょう。

終わりに

最初は、カスタムスキルからEchoデバイスへの通知と、Echoデバイスからカスタムスキルへの通知の両方を書くつもりでしたが、前者だけで紙面と体力が付きました。後者は次回に。

あと、Lambdaでデバッグが面倒であれば、自身で立ち上げたRestfulサーバでデバッグできるようにもしていますので、以下もご参考まで。

SwaggerでLambdaのデバッグ環境を作る(4):Alexaをデバッグする

以上

4
2
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
4
2