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

今回は、[ESP32をAlexa Gadgets Toolkitデバイスにしよう]( https://qiita.com/poruruba/items/e4559e18cb90cd0a3b81) に続いて、カスタムインタフェースに対応させます。

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/afa7d1c1-b553-6324-7c5a-e279139de42f.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0f90e72b-a1df-a595-93a6-a697333b382e.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/1cdb783f-c473-0be1-9be2-bb506efab3d5.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/be3ec6ab-8909-78b9-24ef-f14905a9eb4c.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/4f375d48-1755-b83e-3a51-d23d121f8714.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/2542e577-fe62-52d5-3d82-602091253828.png)

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/95a8d5e0-b1ba-74e7-41f9-e2211713d872.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/862df73a-c8af-fe98-d25f-1abfdf6d68b0.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はこんな感じです。

```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

```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;
```

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

```js: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が該当します。

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

```c: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");
```

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

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

以下の辺りです。

```c: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つ以内のメンバがある前提にしています)

```c: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/7d7ab642-eb75-15c2-a39e-25901b0c37c8.png)

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

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

(参考)
　[便利ページ：Javascriptでアイコンファイルを生成する](https://qiita.com/poruruba/items/268eb6b1f58bce3cff51)

さらに進んで、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/fc17345e-25b7-fc4b-36e6-ccc00c0394f1.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0283fc2f-10db-3b9a-d86e-5fbf7246fe3d.png)

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

以下を参考にしてください。
　[SwaggerでLambdaのデバッグ環境を作る(4)：Alexaをデバッグする](https://qiita.com/poruruba/items/0e13eb7146f4d59ebd4e#%E3%82%B9%E3%83%9E%E3%83%9B%E3%81%AEalexa%E3%82%A2%E3%83%97%E3%83%AA%E3%81%A7%E8%A9%A6%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B)

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

#動作を確認する

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

#終わりに

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

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

[SwaggerでLambdaのデバッグ環境を作る(4)：Alexaをデバッグする]( https://qiita.com/poruruba/items/0e13eb7146f4d59ebd4e)

以上