Alexa Widgets をスキルに実装する

# Alexa Widgetsとは

##### ウィジェットとは

GUIの一部を切り出して単独のアプリとして使えるようにしたもの。

> ウィジェット（widget）は、一般的には、スマートフォンのホーム画面などに設置して常時表示・動作・操作などを可能とする、小規模なアプリ（アプレット）のこと。2010年代半ば以降は、Androidスマートフォンにおける同機能を指す意味で用いられることが多い。「ガジェット」と呼ばれることもある。
> 
> もともとウィジェットとはコンピュータのGUI（グラフィカルユーザーインターフェース）上で機能する部品というか構成要素を総称する語である。後にGUIの機能を単独のパーツとして切り出して使えるような形態のソフトウェアを指す用法が主流となった。
> 
> 2010年代には「Windows 7」の標準機能として「Windows デスクトップ ガジェット」が提供されたが、これはいわゆるウィジェットである。
> [weblio](https://www.weblio.jp/content/Widget)

##### Alexa ウィジェットとは

Echo Show 8 / 10 / 15 にウィジェット機能が搭載されている。
Alexa活用法、よく使うスマートホーム、天気、買い物リストなどのウィジェットがあり、最新情報が常に更新され、また必要な情報へ素早くアクセスすることができる。

Alexa ウィジェットはウィジェットギャラリーから追加でき、ウィジェットパネルには最大10個のウィジェットが表示できる。

ウィジェットパネルは、Echo Show 15 はホーム画面の一部として表示されるのでストレスなくアクセスできる。Echo Show 8およびEcho Show 10についてはデバイスの画面を右端から左にスワイプすることでウィジェットパネルが表示される仕組みなので、ちょっとだけ面倒。


# 前提知識
- Alexa スキル開発
- APLパッケージ（APL）

# Alexa ウィジェットの構成要素

##### APLドキュメント
ウィジェットに表示するテンプレートを定義します。ウィジェット ドキュメントでは、標準 APL 機能のサブセットを使用できます。応答性の高いコンポーネントとテンプレートのサブセットはウィジェットをサポートします。

##### APLパッケージ
ウィジェットをスキルに関連付けるもので、パッケージ内には、APLドキュメント、初期データ、マニフェストおよびその他の情報を定義する。

##### DataStore
データを保存するための `デバイス上にある領域` で、ウィジェットからアクセスができる。わざわざスキルを起動することなくDataStoreデータを取得し表示することがでる。そのため、DataStoreは定期的にData Store REST APIを呼び出してデータを最新化しておく必要がある。ウィジェットに情報を表示しないのであれば、DataStoreは特に意識しなくてよい。

##### Alexa.DataStore.PackageManager インターフェース
Alexa がデバイス上で APL パッケージをインストール、削除、更新するときにスキルに通知するリクエストを提供します。スキルマニフェストで設定しておく。

##### Alexa.DataStore インターフェース
データストアにプッシュされた更新が失敗したときにスキルに通知するリクエスト。スキルマニフェストで設定しておく。

##### Data Store REST API
デバイス上にあるDataStoreを更新するためのAPI。スキル内部で情報更新が発生した場合と、スキル外で情報更新が必要な場合にAPIを呼び出してDataStoreを更新する。スキルの永続化情報をDynamoDBなどに保存している場合は、DataStoreとDyanamoDBとの同期も意識する必要がある。

# 概要

ポイントはDataStore。ウィジェットは、スキルが起動していなくても最新データをDataStoreから取得することができるところがポイント。DataStoreを更新するには `Data Store REST API` でDataStore内に保管されているデータを更新してあげる必要がある。ただ `Data Store REST API` を呼べればデータは更新できるので、必ずしもスキルを実行する必要はなく、APIが呼べる環境であれば何でもよい。注意すべきは、DataStoreはスキルから参照できないので、DynamoDBなどの永続化ストレージでデータを保存している場合は、DynamoDBとDataStoreの双方を更新して同期をとっておく必要がある。そんなに難しいものではない。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/5e78d057-dc69-1b80-0901-96788711772f.png)

詳細なアーキテクチャは、[Alexa Developers Tech Talk: Widgets (YouTube)](https://www.youtube.com/watch?v=Hv645V_Kzo4) の25分50秒あたりで表示される図を見ると瞬時に理解できると思う。

# 実装

ウィジェットを作成する手順は以下になる。

1. スキルマニフェストの設定をする
2. データストアパッケージを作る（APLパッケージ）
3. ハンドラーを実装する（ウィジェット、APL Event）

※ウィジェットで追加や修正が必要なファイルは以下

``` 
SKILL
+---lambda
|   |   index.js                      // ハンドラーを実装する（ウィジェット、APL Event）
|   |   :
\---skill-package
    |   skill.json                    // スキルマニフェストの設定をする（インターフェースとか）
    +---dataStorePackages
    |   \---egg-size                  // データストアパッケージを作る（APLパッケージ）
    |       |   manifest.json         //     APLパッケージのマニフェスト
    |       +---datasources
    |       |       default.json      //     画像URLや初期値などのデータセット
    |       +---documents
    |       |       document.json     //     ウィジェットを表示するAPLドキュメント
    |       \---presentations
    |               default.tpl       //     APLパッケージのテンプレート
```

##### 1. スキルマニフェストの設定をする

`データストアパッケージ` `データストア` `Alexa Extensions` の設定をする。Developer Consoleでも、skill.json でもどちらでもできる。

**skill.json**
`skill.json`の`interfaces`に以下を追加する。Developer Console には パケージIDを記載する欄はないみたいだけど、おそらくウィジェット名がここに設定されるのだと思う。

``` json:skill.json
      "custom": {
        "interfaces": [
          {
            "packages": [
              {
                "id": "egg-size"                           ※ここにパッケージIDの記載が必要らしい
              }
            ],
            "type": "ALEXA_DATASTORE_PACKAGEMANAGER"       ※Alexa.DataStore.PackageManager インターフェース
          },
          {
            "type": "ALEXA_DATA_STORE"                     ※Alexa.DataStore インターフェース
          },
          {
            "requestedExtensions": [
              {
                "uri": "alexaext:datastore:10"             ※DataStore
              }
            ],
            "type": "ALEXA_EXTENSION"
          }
        ],
```

　
**Developer Console**
`インターフェース`セクションで、データストアパッケージ（**Alexa.DataStore.PackageManager インターフェース**）とデータストア（**Alexa.DataStore インターフェース**）の許可をし、AlexaExtensionsのデータストア（**DataStore**）を使用するをチェックする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/18268970-bf2f-c5b8-1724-7bf061c07eb0.png)


#### 2. データストアパッケージを作る（APLパッケージ）

こちらもDeveloper Console とコマンドラインどちらかでも作成可能。


コマンドラインでやるなら、`skill-package` ディレクトリの配下に、`dataStorePackages` ディレクトリを作成する。その中にAPLパッケージを定義する。ディレクトリ名＝パッケージ名＝パッケージIDだと思う。

APLパッケージの構造はこんな感じ。

``` 
SKILL
\---skill-package
    +---dataStorePackages
    |   \---egg-size                  // APLパッケージ名
    |       |   manifest.json
    |       +---datasources
    |       |       default.json
    |       +---documents
    |       |       document.json
    |       \---presentations
    |               default.tpl
```

**presentations/default.tpl**
スキルパッケージテンプレート。拡張子が `.tpl` になっている。こいつがAPLドキュメントとデータソースを束ねている。

``` json:presentations/default.tpl
{
  "type": "APL_PRESENTATION",
  "documentUrl": "documents/document.json",
  "datasourceUrl": "datasources/default.json"
}
```

**datasources/default.json**
データソースですね。スキル名とかURLとかを記載しておく。objectIdがサンプルのままでいけてないな。

``` json:default.json
{
    "eggTimerData": {
        "type": "object",
        "objectId": "alexaPhotoSample",
        "title": "ゆでたまごタイマー",
        "backgroundImage": {
            "sources": [
                {
                    "url": "https://dl.dropboxusercontent.com/s/qa6uyu774ku8yz6/WidgetBGImage.png",
                    "size": "large"
                }
            ]
        }
    }
}
```

**documents/document.json**
APLドキュメント。ウィジェットを表示するための定義。

``` json:document.json
{
    "type": "APL",
    "version": "2023.1",
    "license": "Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0\nLicensed under the Amazon Software License  http://aws.amazon.com/asl/",
    "import": [
        {
            "name": "alexa-layouts",
            "version": "1.6.0"
        }
    ],
    "extensions": [
        {
            "name": "DataStore",
            "uri": "alexaext:datastore:10"
        }
    ],
    "settings": {
        "DataStore": {
            "dataBindings": [
                {
                    "namespace": "eggTimerNamespace",
                    "key": "eggTimerDataStore",
                    "dataBindingName": "eggTimerDataStore",
                    "dataType": "OBJECT"
                }
            ]
        }
    },
    "mainTemplate": {
        "parameters": [
            "eggTimerData"
        ],
        "bind": [
        ],
        "items": [
            {
                "type": "Container",
                "height": "100vh",
                "width": "100vw",
                "items": [
                    {
                        "type": "AlexaImage",
                        "imageRoundedCorner": false,
                        "imageSource": "${eggTimerData.backgroundImage.sources[0].url}",
                        "imageWidth": "100%",
                        "imageHeight": "100%",
                        "imageScale": "best-fill"
                    },
                    {
                        "type": "VectorGraphic",
                        "position": "absolute",
                        "height": "100%",
                        "width": "100%",
                        "source": "alexaPhotoOverlayScrim",
                        "scale": "fill"
                    },
                    {
                        "type": "TouchWrapper",
                        "position": "absolute",
                        "id": "openSkill",
                        "item": {
                            "type": "AlexaHeader",
                            "position": "absolute",
                            "headerTitle": "${eggTimerData.title}"
                        },
                        "onPress": [
                            {
                                "type": "SendEvent",
                                "arguments": [
                                    "openSkill"
                                ],
                                "flags": {
                                    "interactionMode": "STANDARD"
                                }
                            }
                        ]
                    },
                    {
                        "type": "Container",
                        "position": "absolute",
                        "bottom": "0",
                        "items": [
                            {
                                "type": "AlexaFooterActionButton",
                                "buttonStyle": "ingress",
                                "buttonText": "S サイズ",
                                "primaryAction": [
                                    {
                                        "type": "SendEvent",
                                        "arguments": [
                                            "WidgetSizeId",
                                            "S"
                                        ],
                                        "flags": {
                                            "interactionMode": "INLINE"
                                        }
                                    }
                                ]
                            },
                            {
                                "type": "AlexaFooterActionButton",
                                "buttonStyle": "ingress",
                                "buttonText": "M サイズ",
                                "primaryAction": [
                                    {
                                        "type": "SendEvent",
                                        "arguments": [
                                            "WidgetSizeId",
                                            "M"
                                        ],
                                        "flags": {
                                            "interactionMode": "INLINE"
                                        }
                                    }
                                ]
                            },
                            {
                                "type": "AlexaFooterActionButton",
                                "buttonStyle": "ingress",
                                "buttonText": "L サイズ",
                                "primaryAction": [
                                    {
                                        "type": "SendEvent",
                                        "arguments": [
                                            "WidgetSizeId",
                                            "L"
                                        ],
                                        "flags": {
                                            "interactionMode": "INLINE"
                                        }
                                    }
                                ]
                            }
                        ]
                    }
                ]
            }
        ]
    }
}
```


**Developer Console**
マルチモーダルから `ウィジェットの作成` をクリックするとウィジェットのテンプレートが選べるので目的に合ったテンプレートを選択する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/99e3f5cc-4576-7cb5-1efa-327ad289e6c9.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/aef47e2c-eb3b-07c1-1eae-49fe6b5bf31f.png)

自分のスキルに合わせて、画像はボタンの配置などを修正する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/858c221a-0077-98f0-0bcb-ecff282b343f.png)

APLパッケージのマニフェストを記載するのだが、ここはJSONで書かないといけないみたい。今回はGUIを使っていないので、このへんは想像で書いてる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/94c0b8cb-27c1-d5b1-5c77-c8d477eea8d7.png)

`AlexaFooterActionButton`を３コに増やして、画像を差し替えて完成。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/54bcb201-d927-1fa6-99e7-2baf35f73ab4.png)





##### 3. ハンドラーを実装する（ウィジェット、APL Event）


ホスティングで作るか、Lambdaで作るかだけど、基本同じ。
ウィジェットのイベント（インストール、削除、更新、エラー）を拾うハンドラーと、APLのEventを拾うハンドラーを作成する。`.addRequestHandlers`への追加もお忘れなく。

``` javascript:index.js
// ウィジェットがインストールされた時に呼ばれる
const InstallWidgetRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === "Alexa.DataStore.PackageManager.UsagesInstalled";
    },
    async handle(handlerInput) {
        // ※DataStoreを更新したり・・・
        // ※特に何もする必要がなければ、ログ出力して、.getResponse(); だけでＯＫ
        return handlerInput.responseBuilder.getResponse();
    }
};

// ウィジェットが削除された時に呼ばれる
const RemoveWidgetRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === "Alexa.DataStore.PackageManager.UsagesRemoved";
    },
    async handle(handlerInput) {
        // ※DataStoreを更新したり・・・
        // ※特に何もする必要がなければ、ログ出力して、.getResponse(); だけでＯＫ
        return handlerInput.responseBuilder.getResponse();
    }
};

// ウィジェットが更新された時に呼ばれる
const UpdateWidgetRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === "Alexa.DataStore.PackageManager.UpdateRequest";
    },
    async handle(handlerInput) {
        // ※DataStoreを更新したり・・・
        // ※特に何もする必要がなければ、ログ出力して、.getResponse(); だけでＯＫ
        return handlerInput.responseBuilder.getResponse();
    }
};

// ウィジェットのインストール時にエラーが発生した場合に呼ばれる
const WidgetInstallationErrorHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === "Alexa.DataStore.PackageManager.InstallationError";
    },
    async handle(handlerInput) {
        :
        :
        return handlerInput.responseBuilder
            .speak('ウィジェットインストールでエラーが発生しました。')
            .getResponse();
    }
};

// APLのSendEventが発動された場合に呼ばれる
const APLEventHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === "Alexa.Presentation.APL.UserEvent";
    },
    async handle(handlerInput) {
        const eventType = handlerInput.requestEnvelope.request.arguments[0]; 
        switch(eventType){
            case 'openSkill': 
                return LaunchRequestHandler.handle(handlerInput);
            case 'WidgetSizeId': 
                return SizeIntentHandler.handle(handlerInput);
            default:
                return CancelAndStopIntentHandler(handlerInput);
        }
    }
};
```

# テスト

`インストール`を選択して、テストするAlexaデバイスを選択して、`送信`をクリックすることで、ウィジェットがデバイスへインストールされるので、その後は、Alexaデバイスで実際に操作してみる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/79cfb7ac-490f-4ff7-b663-3b6b7ae5427e.png)

デバイスにウィジェットがインストールされるので、実際にデバイスを操作してテストを実施する。

![IMG_9860.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/8cd67cdd-8c18-5823-ce98-d68530e619de.jpeg)

問題なければ申請する。 

# 公開した

今回は[ゆでたまごタイマー](https://amzn.to/3BOOUWM)にAlexa Widgetsを追加して公開した。このウィジェットはハッピーパスのショートカット的なものなのでスキルを知ってる人には便利だと思う（思っている）が、知らない人にとっては何のことかサッパリわからないものになってしまっている。
このへん、もう一工夫する必要がありそう。


　
　


もろもろ足りない部分は今後追記していきます。


# 参考
- [About Widgets and Alexa Presentation Language (APL)](https://developer.amazon.com/en-US/docs/alexa/alexa-presentation-language/about-widgets-and-apl.html)
- [APL Widgets デザイン](https://developer.amazon.com/ja-JP/alexa/alexa-haus/visual-design/apl-templates/apl-widgets)
- [Get started with Alexa Widgets in 15 minutes or less](https://developer.amazon.com/en-US/blogs/alexa/alexa-skills-kit/2023/04/alexa-widgets-april-2023?sc_category=Owned&sc_channel=DC&sc_campaign=ASK-apl-q2-23&sc_publisher=ASK&sc_content=Blog&sc_detail=Learn_More&sc_funnel=Awareness&sc_country=WW&sc_segment=Skill_Builders)
- [Alexa Developers Tech Talk: Widgets (YouTube)](https://www.youtube.com/watch?v=Hv645V_Kzo4)
- [ゆでたまごタイマー](https://amzn.to/3BOOUWM)