Google Home, Nest Hub向けにアクションを作ろう - 第8話: Interactive Canvasでディスプレイに動物を表示する
前回のやり残し
前回のコードでは、conv.close()関数で会話を終了する際、したのコードですでに話した動物名のリストを空にしていました。
consumedAnimals.length = 0;
これで十分なのですが、シミューレーターでテストしていると、途中でアプリをCancelボタンでキャンセルして何度も試したくなると思います。このとき、上のコードは実行されずに会話を抜けるため、cosumedAnimalsに動物のリストが残ってしまいますと。するとアクションを再び立ち上げたときに前回の会話で話した動物がすでに話した動物として存在してしまうため、始めた言った動物なのに「2回目ですね。」と言われてしまいます。
そこで、会話の始めに必ずcosumedAnimalsを空にする処理を入れたいと思います。
会話の始めとはどこでしょうか?それはDefault Welcome Intentです。以前Default Welcome Intentは「挨拶のためのIntent」とお話しましたが、起動時に必ず呼ばれます。
このDefault Welcome Intentを一度Webhookで受け取ってあげて、ここで、consumedAnimalsを空にする処理を入れる方法を説明します。
まず、DialogFlowコンソールに戻って、Intentsから、Default Welcome Intentを選択します。そして、一番したの、Fulfillmentというところの
Enable call for this intent
のチェックマークをオンにします。
これで、Default Welcome Intentは、Text Responseにリストアップした
コケッコーとカッカドゥーをそのまま返すのではなく、リクエストを一度Webhookに投げ、Webhookからの応答を返すように切り替わります。
つぎにFirebase Functionsのindex.jsに下のハンドラーを追加します。
app.intent('Default Welcome Intent', (conv) => {
consumedAnimals.length = 0;
//Assistantが動物を答える
conv.ask(`コケコッコー`);
});
ハンドラーが'Default Welcome Intent'という名前に対して登録されているのがわかると思います。この名前を間違えると、Default Welcome Intentのリクエストをキャッチできないので、注意してください。
このハンドラーの中で、
consumedAnimals.length = 0;
を実行して、すでに言った動物のリストを初期化します。
ちなみのこのハンドラーでは、「コケコッコー」という挨拶を返すようにしています。ここは個人の好みで変えてください。
(Default Welcomeインテントはシミューレーターなどでテストされるときに毎回再生されるので、開発中は短い言葉にしておいたほうが開発スピードがあがります。)
Interactive Canvasで動物の写真を表示する
「簡易版動物しりとり」では、Google Assitantが喋った動物の写真をGoogle Nest Hubのディスプレイに表示してみたいと思います。
まずanimals.jsに下のようなオブジェクトを追加します。
var animalsPics = {
"イヌ": "inu.jpg",
"ウシ": "ushi.jpg",
"ウマ": "uma.jpg",
"カバ": "kaba.jpg",
...
"コアラ": "koala.jpg",
"グリズリー": "higuma.jpg",
"ミーアキャット": "meerkat.jpg",
"ミンク": "mink.jpg",
};
これは、各動物の写真名を収納したものになります。非常に長いリストなので、下にコードを添付しました。
次にindex.jsを開いてanimalsPicsという変数に、このanimals.jsのなかのanimalsPicsの内容を格納します。
var animalsPics = animalModule.animalsPics
次にindex.jsの上のあたりある以下のコードを
const {dialogflow} = require('actions-on-google');
下のように変更します。
const {dialogflow, HtmlResponse} = require('actions-on-google');
新たにHtmlResponseというクラスをactions-on-googleライブラリからインポートしています。
次に下のコードもindex.jsの上のあたりに挿入してください。
const firebaseConfig = JSON.parse(process.env.FIREBASE_CONFIG);
いま現在皆さんのindex.jsの宣言部分(0行目以降)がしたのようになっていれば問題ありません。
// Actions on Google client libraryから、Dialogflowモジュールをインポートする
const {dialogflow, HtmlResponse} = require('actions-on-google');
// firebase-functions packageをインポートする
const functions = require('firebase-functions');
const firebaseConfig = JSON.parse(process.env.FIREBASE_CONFIG);
var animalModule = require('./animal');
var animals = animalModule.animals
var animalsPics = animalModule.animalsPics
// Dialogflow clientインスタンスを作る
const app = dialogflow({debug: true});
const consumedAnimals = []
HTML Responseを返答に加える
Interactive Canvasの仕組みは、HTMLがベースになっています。スマートディスプレイ側で画面にHTMLを表示することで、ディスプレイにGUIを表示しています。
Firebase Functionsは、「HTMLを表示してくれ」というレスポンスをGoogle Nest Hubに送ることによって、HTML表示のイベントを発火させます。
この「HTMLを表示してくれ」というレスポンスがHtml Responseと呼ばれるものです。
下のようにconv.ask関数に対してHtmlResponseというオブジェクトを渡すことによって、「HTMLを表示してくれ」というレスポンスを返すことができます。
//Interactive Canvasに対応していればHtmlResponseを返す
if(hasInteractiveCanvas(conv)){
conv.ask(new HtmlResponse({
url: `https://${firebaseConfig.projectId}.firebaseapp.com/`,
data: {
scene: 'animal',
name: newAnimal,
name_en: 'newAnimal',
picture: animalsPics[newAnimal]
}
}));
}
//Assistantが動物を答える
conv.ask(newAnimal);
このコードの下に、いままで、conv.ask(newAnimal)というコードがありますが、これは前回から変わらず動物名を音声で話すためのレスポンスですので、このコードでは、ディプレイ向けのconv.ask(new HtmlResponse())と、スピーカーむけのconv.ask(newAnimal)をデバイスに送っていることがわかると思います。
もう少し、この部分を深堀りしてみたいと思います。
この回を終えると、下のようにスマートディスプレイに動物の写真が表示されるようになりますが、このときFirebase Functionsからどのようなレスポンスが来ているのか見てみましょう。
この画面で、RESPONSEタブを開くと、下のようにFirebase Functionsからのレスポンスが見えます。
レスポンスタブの中に、htmlResponseというJSONオブジェクトと、simpleResponseというJSONオブジェクトがあるのがわかるでしょう。
htmlResponseが入っているとGoogle Nest Hubは、この中身を元に画面上いHTMLを表示しようと試みます。
simpleResponseが入っていると、Google Nest Hubは、音声で中に入っているtextToSpeechの文字を読み上げます。
下にRESPONSEタブで表示されえているResponseの中身をペーストしました。
{
"payload": {
"google": {
"expectUserResponse": true,
"richResponse": {
"items": [
{
"htmlResponse": {
"url": "https://parrot-9855a.firebaseapp.com/",
"updatedState": {
"scene": "animal",
"name": "シロクマ",
"picture": "polarbear.jpg"
}
}
},
{
"simpleResponse": {
"textToSpeech": "シロクマ"
}
}
]
}
}
}
}
RESPONSEタブを覗くととFirebase Functions(サーバー)とGoogle Nest Hub(スマートディスプレイ)の間で何が行われているか、理解が深まるので、開発をしながらちょいちょい覗いてみてください。
hasInteractiveCanvas(conv)とは?
もう一度Firebase Functionsのコードに戻ると、hasInteractiveCanvasという関数を読んで、その関数がtrueを返したときだけ、HtmlResponseを返しているのがわかると思います。
function hasInteractiveCanvas(conv) {
const hasInteractiveCanvas =
conv.surface.capabilities.has('actions.capability.INTERACTIVE_CANVAS');
console.log("hasInteractiveCanvas", hasInteractiveCanvas)
return hasInteractiveCanvas
}
index.jsの下のほうに、この関数を添付してください。
この関数では、リクエストを送ってきたデバイスがディスプレイをもったデバイスかどうかを判断しています。
リクエストを送って来たデバイスの情報は、convオブジェクトの中に入っていますので、このコードのようにconvの中身を見て判断します。
もし、デバイスがディスプレイを持っていればこの関数がtrueを返すのでえ、HtmlReponseが送られ、持っていなければfalseを返すので、conv.ask(newAnimal)で、音声再生用のテキストデータだけがデバイスに送られます。
HTMLResponseの中のurlとdata
もう一度、詳しくHtmlReponseの中身を見ていきましょう。
conv.ask(new HtmlResponse({
url: `https://${firebaseConfig.projectId}.firebaseapp.com/`,
data: {
scene: 'animal',
name: newAnimal,
picture: animalsPics[newAnimal]
}
}));
HtmlResponseのなかにurlとdataという値を入れて送っています。
urlはInteractive Canvasを最初に呼び出すときに必須のものです。
先程初期化したfirebaseConfigの中のprojectIdをつかって、Firebase HostingにホスティングされるクライアントコードのWebページのURLを生成してセットする必要があります。
このurlがないと、Google NestHubのGoogle AssitantはどのURLからUIを描画するためのフロントエンドのHTML/CSS/JSのコードをダウンロードしていいのわからなくなってしまうため、urlのセットを忘れずに行いましょう。
dataの中にはスマートディスプレイのクライアントコードに渡したいデータを詰め込みます。
今回は、
scene: いまから表示したいページが動物のページ(シーン)であること
name: いまから表示したい動物の名前
picture: いまから表示したい動物の画像ファイル名
をdataに入れて渡しています。
これで、Firebase Functions側の準備はすべて整いました。
スマートディスプレイに表示するクライアントコード
Google Nest Hubのディスプレイにユーザーインターフェースを表示するためのHTMLのコードは、プロジェクトのpublicフォルダ以下にあります。
これからはこのpublicフォルダ以下のHTMLのコードを編集していきます。
parrot
├── firebase.json
├── functions
│ ├── animal.js
│ ├── index.js
│ ├── node_modules
│ ├── package-lock.json
│ └── package.json
└── public
├── 404.html
└── index.html
もう一度上のプロジェクトのファイル階層をみると
functions以下 → Firebase Functions(サーバー側で動くコード)
public以下 → Firebase Hostingにホストされクライアント側にダウンロードされて動くコード
という関係性が見えてきます。
functions以下 → バックエンド
public以下 → フロントエンド
と言ったほうがわかりやすい方もいると思います。
クライアント側(フロントエンド側)のプロジェクトのセットアップ
現在はindex.htmlがあるだけなので、ここにCSSファイルを置くためのcssフォルダ、Javaスクリプトファイルを置くためのjsフォルダ、画像を置くためのimagesフォルダをつくって、下のような構成を作ってください。
parrot
├── firebase.json
├── functions
│ ├── animal.js
│ ├── index.js
│ ├── node_modules
│ ├── package-lock.json
│ └── package.json
└── public
├── 404.html
├── css
├── images
├── index.html
└── js
画像の用意
まず、imagesですがここには、今回表示させる動物の画像を入れます。動物しりとりで使っている動物の画像のうちPublic Domainで公開されている画像のみを下のzipファイルに圧縮しましたので、これを解凍して、images以下に配置してください。
(Public Domainのみの画像のため、すべての動物の写真は網羅していません。)
images.zipのなかには、その他に、
no_image.jpg:該当する動物の写真がなかったときに出す画像
welcome.png:アプリのアイコン画像
が入っています。
CSSの用意
cssフォルダの下に以下のCSSファイルを配置してください。
CSSファイルには必要最低限のものだけが入っており、今回のチュートリアルの趣旨からは重要でないため説明は割愛します。
Javascriptの用意
からのindex.jsというJavascriptファイルを作成しjsフォルダの中に配置してください。
Javascriptの説明は、HTMLの説明の後にします。
HTMLの用意
既存のindex.htmlを下のコードで上書きしてください。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>[簡易版]動物しりとり</title>
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
<link rel="stylesheet" href="css/index.css" />
<script src="https://www.gstatic.com/assistant/interactivecanvas/api/interactive_canvas.min.js"></script>
</head>
<body>
<div class="container">
<div id="animal">
<div id="image_block">
<img id="animalimage" />
</div>
<div id="name">
</div>
</div>
</div>
<script src="js/index.js"></script>
</body>
</html>
見てのように非常にシンプルなHTMLのコードになっています。
まず、headタグの中で、重要な部分は下のようにinteractive_canvas.min.jsをインクルードすることです。
<script src="https://www.gstatic.com/assistant/interactivecanvas/api/interactive_canvas.min.js"></script>
bodyタグの中は、画像表示用の以下のimgタグと
<img id="animalimage" />
動物名表示用の以下のタグのid名だけ覚えておいてください。
<div id="name">
一番下で以下のようにjsフォルダ以下に先程置いたindex.jsをロードしています。
<script src="js/index.js"></script>
index.jsの中身
ここまでは非常にノーマルなhtmlのコードだと思いますが、これから説明するindex.jsの中でInteractive Canvasならではのコーディングを行います。と言ってもコードは非常にシンプルで下のようになります。
'use strict';
window.onload = () => {
interactiveCanvas.ready({
onUpdate(data) {
if (data.scene === 'animal') {
if(data.picture){
document.querySelector('#animalimage').src = `images/${data.picture}`;
}else{
document.querySelector('#animalimage').src = `images/no_image.jpg`;
}
document.querySelector('#name').innerText = `${data.name}`;
}
}
})
}
まずWindowがロードされたら、interactiveCanvasというオブジェクトのreadyというイベントにリスナーを登録し、onUpdateという関数をオーバーライドします。このなかで、dataという値を受け取るのですが、これが、先程Firebase Functionsで登録したdataです。
先程のFirebase Functionsのコードを思い出してみてください。dataには下の3つの情報を入れました。
scene: いまから表示したいページが動物のページ(シーン)であること
name: いまから表示したい動物の名前
picture: いまから表示したい動物の画像ファイル名
これらをonUpdateのなかで取り出しています。
onUpdate(data) {
if (data.scene === 'animal') {
if(data.picture){
document.querySelector('#animalimage').src = `images/${data.picture}`;
}else{
document.querySelector('#animalimage').src = `images/no_image.jpg`;
}
document.querySelector('#name').innerText = `${data.name}`;
}
}
まず、sceneがanimalだったらという判定をしています。
これは将来的に状況によってシーンを切り替えるような実装をしたいとき、たとえば、動物画面と勝ち負けの画面を交互に出すような場合や最初に説明の画面を出したいとき、などはこのsceneの値でページの内容を切り替えたいのでこのようなsceneの判定を行っています。
次に、data.pictureの中身があるかどうかをチェックし、あれば
`/images/${data.picture}`
で、images以下の画像ファイルのパスを作ってimgタグのsrcにしています。
これで先程imagesの中に入れた動物の画像のなかにある、該当する動物の画像が表示されます。
data.pictureの中身がなければno_image.jpgを表示します。
最後に
document.querySelector('#name').innerText = `${data.name}`;
の箇所で、動物の名前を#nameのidがついたdivタグの中に表示します。
これで実装は完了しました。
firebase deploy
を実行してコードをFirebase FunctionsとFirebase Hostingにディプロイしてください。(firebase deployコマンド1回で両方へのディプロイが自動的に行われますので、非常に便利ですね。)
Actions on Googleシミュレーターで実行してみて、下のように動物の写真がでれば成功です!
ナビゲーションバーの高さを考慮してレイアウトする
これでInteractive Canvas上に画像を表示することができましたが、上の狸の画像の上のほうが少し切れていることに気づいた人もいるのではないでしょうか?
Google Nest Hubはナビゲーションバーを上部にオーバーレイして表示するため、これが画像の上に被ってしまっているのです。
ナビゲーションバーの高さを動的に取得し、ナビゲーションバーの高さだけ、画像を下に下げてレイアウトするテクニックを紹介します。
interactiveCanvasオブジェクトには、getHeaderHeightPxという非同期の関数が用意されており、これでナビゲーションバーの高さを取得できます。
interactiveCanvas.getHeaderHeightPx().then((height) => {
document.body.style.paddingTop = `${height}px`;
})
この関数を下のように、onloadの中に追加します。
'use strict';
window.onload = () => {
interactiveCanvas.ready({
onUpdate(data) {
if (data.scene === 'animal') {
if(data.picture){
document.querySelector('#animalimage').src = `images/${data.picture}`;
}else{
document.querySelector('#animalimage').src = `images/no_image.jpg`;
}
document.querySelector('#name').innerText = `${data.name}`;
}
}
})
interactiveCanvas.getHeaderHeightPx().then((height) => {
document.body.style.paddingTop = `${height}px`;
})
}
もう一度、firebase deployでソースを展開してテストしてみましょう。
どうでしょうか?上のように、動物の写真がナビゲーションバーの下から綺麗に表示されたと思います。
ちなみに、該当する動物の画像がない場合は下のような画像が表示されます
これで、ようやくGoogle Nest Hubにグラフィカルユーザーインタフェースを表示できるアクションが完成しました!
ここまでの道のりは皆さんには非常にながかったと思います。(私はここまでえくるのに数ヶ月を要しました💧)お疲れさまでした!
Google Nest Hubだけでなく、Androidスマートフォンにむかって「OK、Google、テスト用アプリにつないで」と呼びかけてアクションを起動しても下のように、動物の写真が表示できるようになっていますのでお試しください。
今回のまとめ
Default Welcome IntentをWebhookで引っ掛けて、必要な初期化処理をする。
animals.jsを更新して、動物の画像ファイル名のリストを追加。
actions-on-googleライブラリからHtmlResponseクラスをインポート。
HtmlResponseとSimpleResponseをクライアントに返すコードを実装。
Actions On GoogleシミューレーターのRESPONSEタブでクライアントに送られてきたレスポンスの実態を確認。
リクエストを送ってきたデバイスがディスプレイを持ったデバイスか確認する方法を実装。
functions以下 → バックエンド、public以下 → フロントエンド
HTML/CSS/JSの準備
interactiveCanvasにハンドラーを実装。
ハンドラー内でのデータの取り出し方を学んだ。
このブログに関する質問やActions on Googleの開発の相談はこちらから↓↓↓
@mizutory
mizutori@goldrushcomputing.com
次回はGoogle Nest Hubなどのスマートディスプレイに表示させたGUIを通じてユーザーからのインタラクションを受け取る方法を説明します↓↓↓