Google Nest Hubに、WebRTCで映像配信してみた

Google Nest Hubは、Googleが提供するスマートディスプレイです。
「OK Google」ができる上に、ディスプレイがついているので、いろいろ役に立ちそうです。

ということで、ディスプレイがついているので、映像配信をしてあげようと思います。映像配信と言えば、最近はやりのWebRTCを使います。

Googleのスマートディスプレイで、WebRTCを配信するために、以下を試してみました。

①Googleスマートホームのカメラとして実装する。
②GoogleアシスタントのInteractive Canvasを使う。

①が、もともとやりたかったことです。

Smart Home CameraStream Trait Schema

https://developers.google.com/assistant/smarthome/traits/camerastream?hl=ja

上記のページを見ると、スマートホームのトレイトであるCameraStreamがWebRTCに対応しているではないか。。。。
結論からいうと、うまく動きませんでした。
対応デバイスとして、smart displays、Chromecast-enabled devices、smartphonesとあるので、てっきりGoogle Homeアプリからつかえるのかと思いきや動かなかったですし、smart displaysで動かそうにも、cameraStreamSignalingUrl にリクエストを投げてくれず、断念。
以下ご参考まで。

　[Google Smart HomeデバイスをAWS IoTで実装してみた](https://qiita.com/poruruba/items/c0d7ceff7e400c6c7768)
　[ESP32をGoogle Smart Homeデバイスにする](https://qiita.com/poruruba/items/337722ef13cf44986b37)

今回は②で実現します。

Interactive Canvas

https://developers.google.com/assistant/interactivecanvas

スマートディスプレイにHTMLを表示できるんです。
なので、以前以下の投稿で、AWS Kinesis Video Streamsを使ったWebRTC配信をした時のページを使えばできそうです。Interactive Canvasも以下の投稿で経験済み。

　[AWS Kinesis Video StreamsでMMDをWebRTCで配信する](https://qiita.com/poruruba/items/7a52d5f01f0f46ec3c63)
　[Actions on GoogleのInteractive Canvasを試してみる](https://qiita.com/poruruba/items/84c75801d327d8fcc9b3)

以降で流れやつまずきやすいところを示しておきます。詳細は、上記の記事をご参照ください。
もろもろのソースコードはGitHubに上げてあります。

poruruba/WebRTC_InteractiveCanvas

https://github.com/poruruba/WebRTC_InteractiveCanvas

# Actions on Googleプロジェクトを作成する

まずは、Actions Consoleから、プロジェクトを作成します。
このときスマートディスプレイにリンクしたアカウントと同じアカウントでログインしておきます。

Actions Console
https://console.actions.google.com/

「New Project」ボタンを押下し、適当なプロジェクト名を入力します。
例えば、「MyInteractiveCanvas」など。
言語は、Japanese、国はJapanを選択。

What kind of Action do you want to build? では、Gameを、
How do you want to build it? では、Blank projectを選択します。

左側のナビゲーションからSettingsを選択し、Display nameに呼び出しやすい名前を入力しておきます。例えば、「テストキャンバス」など

次に、Custom Intentを作成します。
適当な名前でいいのですが、「ContinueIntent」とします。
training phrasesに、「継続して」を入れておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/7678d644-0594-256c-51c6-4db1448f16ae.png)

次に、Sceneを作成します。
とりあえず、名前は「MainScene」とでもしておきます。

User intent handlingには、Intentとして先ほど作成した「ContinueIntent」を割り当て、Call your webhooksにチェックを入れて、「continue」と入力しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/c768d2b6-5673-c37b-e999-b2282dcdc474.png)

次に、Main invocationを選択し、Call your webhookにチェックを入れて、startと入力、Transitionとして、先ほど作成した「MainScene」を選択しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/5da3c7fb-7c79-f2d7-9b3e-30248aea472c.png)

次に、左側のナビゲーションから、Interactive canvasを選択し、「Enable Interactive Canvas with server webhook fulfillment」を選択します。

次に、また左側のナビゲーションからWebhookを選択し、HTTPS endpointを選択、URLにこれから立ち上げるサーバのURLを入力します。
こんな感じ、

　https://XXX.XXX.XXX/canvas-api

ポート番号を付けたい場合はつけて大丈夫です。

# サーバを立ち上げる

基本的には、Githubからダウンロードしたものを展開し、npm installすれば、環境は完成します。
ただ、httpsとするには、certフォルダを作成してssl証明書をいれるか、リバースプロキシを介する必要があります。
受け付けるWebhookの実装は以下にあります。

　api/controllers/canvas-api/index.js

```js:api/controllers/canvas-api/index.js
'use strict';

const base_url_html = '【WebページのURL】';

const AWS_ACCESSKEY_ID = '【AWSのアクセスキーID】';
const AWS_SECRET_ACCESSKEY = '【AWSのアクセスキーシークレット】';

const {
	conversation,
	Canvas,
} = require('@assistant/conversation')

const app = conversation({ debug: true });

app.handle('start', conv => {
	console.log(conv);
	conv.add('これはインタラクティブキャンバスです。');

	if (conv.device.capabilities.includes("INTERACTIVE_CANVAS") ){
		conv.add(new Canvas({
			url: base_url_html + '/interactivecanvas_webrtc/index_viewer.html',
			enableFullScreen: true,
			data: {
				AWS_ACCESSKEY_ID: AWS_ACCESSKEY_ID,
				AWS_SECRET_ACCESSKEY: AWS_SECRET_ACCESSKEY,
			}
		}));
	}else{
		conv.scene.next.name = 'actions.scene.END_CONVERSATION';
		conv.add('この端末はディスプレイがないため対応していません。');
	}
});

app.handle('continue', async conv => {
	console.log(conv);
	conv.add(new Canvas({
		data: {
			message: "continue"
		}
	}));
});

exports.fulfillment = app;
```


それぞれ、startとcontinueをフックしています。
Actions Consoleで入力していたキーワードです。
startは、本アプリを起動した直後に、スマートディスプレイからトリガされます。continueは、KEEP ALIVE的に使っていまして、適当な応答を返しています。（のちほどWebページのところで説明します）

```api/controllers/smarthome/swagger.yaml``` で示した通り、/canvas-apiというエンドポイントで公開するようにしています。

```yaml:api/controllers/smarthome/swagger.yaml
swagger: '2.0'
info:
  version: 'first version'
  title: Lambda Laboratory Server
  
paths:
  /canvas-api:
    post:
      x-handler: fulfillment
      parameters:
        - in: body
          name: body
          schema:
            $ref: "#/definitions/CommonRequest"
      responses:
        200:
          description: Success
          schema:
            $ref: "#/definitions/CommonResponse"
```

やっていることは、startのところが重要で、Canvasで指定のURLを表示するようにスマートディスプレイに伝えています。

npmモジュールの「@assistant/conversation」を使わせてもらっていますが、これで実装が楽になってます。

```js
		conv.add(new Canvas({
			url: base_url_html + '/interactivecanvas_webrtc/index_viewer.html',
			enableFullScreen: true,
			data: {
				AWS_ACCESSKEY_ID: AWS_ACCESSKEY_ID,
				AWS_SECRET_ACCESSKEY: AWS_SECRET_ACCESSKEY,
			}
```

enableFullScreenは、表示するページを全画面で表示させる指示です。
dataにあるデータは、この値がそのままスマートディスプレイで表示されるWebページのJavascriptに渡されるので、任意の振る舞いがJavascriptで実装できます。

ここで渡しているのは、AWSのクレデンシャル情報です。

# Webページの作成

Webページ側のJavascriptを示しておきます。
Webページは、以下に置いてあります。

　public/interactivecanvas_webrtc

```js:public/interactivecanvas_webrtc/js/start.js
'use strict';

//const vConsole = new VConsole();
//window.datgui = new dat.GUI();

const AWS_ACCESSKEY_ID = '';
const AWS_SECRET_ACCESSKEY = '';
const SIGNALING_CHANNEL_NAME = 'Room1';
const SIGNALING_CLIENT_ID = "";
const VIEW_WIDHT = 640;
const VIEW_HEIGHT = 480;
const UPDATE_INTERVAL = 10000;

var updated = false;

var vue_options = {
    el: "#top",
    mixins: [mixins_bootstrap],
    data: {
        aws_accesskey_id: AWS_ACCESSKEY_ID,
        aws_secret_accesskey: AWS_SECRET_ACCESSKEY,
        signaling_channel_name: SIGNALING_CHANNEL_NAME,
        signaling_client_id: SIGNALING_CLIENT_ID,

        width: VIEW_WIDHT,
        height: VIEW_HEIGHT,

        webrtc_opened: false,
        message_logs: '',
        message_data: '',
        signalingClient: null,

        margin: 0,
        video_style: {},
    },
    computed: {
    },
    methods: {
        startViewer: async function() {
            try {
                var params = {
                    accessKeyId: this.aws_accesskey_id,
                    secretAccessKey: this.aws_secret_accesskey,
                    channelName: this.signaling_channel_name,
                    clientId: this.signaling_client_id || getRandomClientId(),
                    openDataChannel: true,
                    useTrickleICE: true,
                };
                this.signalingClient = await startViewer(document.querySelector('#remote-view'), params, (type, event) => {
                    console.log(type, event);
                    if (type == 'sdpAnswer' ){
                        this.webrtc_opened = true;
                        this.panel_close('#webrtc_config_panel');

                        this.video_style = {
                            position: "absolute",
                            top: 0,
                            width: "100%",
                            height: "100%",
                            background: "#FFF"
                        };
                    }else if( type == 'close'){
                        this.webrtc_opened = false;
                        this.panel_open('#webrtc_config_panel');
                    }else if( type == 'message'){
                        var now = new Date().toLocaleString('ja-JP', {});
                        this.message_logs = '[' + now + ' - master] ' + event.event.data + '\n' + this.message_logs;
                    }
                });
            } catch (error) {
                console.error(error);
                alert(error);
            }
        },
        stopViewer: function(){
            stopViewer(this.signalingClient);
        },
        send_message: function(){
            try{
                sendViewerMessage(this.message_data);
                this.message_logs = '[' + new Date().toLocaleString('ja-JP', {}) + ' - local] ' +this.message_data + '\n' + this.message_logs;
            }catch(error){
                console.error(error);
                alert(error);
            }
        }
    },
    created: function(){
    },
    mounted: function(){
        proc_load();


        const callbacks = {
            onUpdate: (data) => {
                console.log(data);

                if( !updated ){
                    this.aws_accesskey_id = data[0].AWS_ACCESSKEY_ID;
                    this.aws_secret_accesskey = data[0].AWS_SECRET_ACCESSKEY;

                    window.interactiveCanvas.getHeaderHeightPx()
                        .then(height => {
                            console.log("getHeaderHeightPx:" + height);
                            this.margin = height;
                        });

                    setInterval(() =>{
                        if ( this.webrtc_opened )
                            window.interactiveCanvas.sendTextQuery("継続して");
                    }, UPDATE_INTERVAL);
                    updated = true;
                }
            },
        };
        window.interactiveCanvas.ready(callbacks);
    }
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);

/* add additional components */
  
window.vue = new Vue( vue_options );

function getRandomClientId() {
    console.log("call: getRandomClientId");

    return Math.random()
        .toString(36)
        .substring(2)
        .toUpperCase();
}
```

大事なのは以下のところです。

```js
        const callbacks = {
            onUpdate: (data) => {
                console.log(data);

                if( !updated ){
                    this.aws_accesskey_id = data[0].AWS_ACCESSKEY_ID;
                    this.aws_secret_accesskey = data[0].AWS_SECRET_ACCESSKEY;

                    window.interactiveCanvas.getHeaderHeightPx()
                        .then(height => {
                            console.log("getHeaderHeightPx:" + height);
                            this.margin = height;
                        });

                    setInterval(() =>{
                        if ( this.webrtc_opened )
                            window.interactiveCanvas.sendTextQuery("継続して");
                    }, UPDATE_INTERVAL);
                    updated = true;
                }
            },
        };
        window.interactiveCanvas.ready(callbacks);
```

```window.interactiveCanvas.ready(callbacks); ```
を呼び出すと、Actions on Googleのサーバと接続し、接続が完了すると、コールバック関数として実装したonUpdateを呼び出してくれます。
このonUpdateは、最初の接続時だけでなく、以降のIntentによるリクエスト/レスポンスの際にも呼ばれます。
最初の接続時には、dataで指定しておいたAWSクレデンシャルが届くので、内部で覚えておきます。

その後、```setInterval```で以下を定期的に呼ぶようにしています。

```js
window.interactiveCanvas.sendTextQuery("継続して")
```

何もしないと、切断されてしまうので、継続的に発話しているように見せかけています。
継続して、と声で言ったことと同じ動作となり、サーバ側ではContinueIntentが一致し、```app.handle(‘continue’ ```でトリガされるという流れです。

あとは、以前の投稿の通り、WebRTC接続することで、映像が配信されてきます。
ついでに、WebRTCの接続が完了すると、HTML Videoタグを全画面表示に切り替えてみやすくなるようにしています。

# 注意事項

以下の部分で指定するurlのポート番号は無し、すなわち443番にしてください。私はこれではまりました。

```js:api\controllers\canvas-api\index.js
		conv.add(new Canvas({
			url: base_url_html + '/interactivecanvas_webrtc/index_viewer.html',
			enableFullScreen: true,
			data: {
				AWS_ACCESSKEY_ID: AWS_ACCESSKEY_ID,
				AWS_SECRET_ACCESSKEY: AWS_SECRET_ACCESSKEY,
			}
		}));
```

もし443番のポート番号で用意するのが難しそうであれば、とりあえず以下を指定してみてください。

https://poruruba.github.io/WebRTC_InteractiveCanvas/public/interactivecanvas_webrtc/index_viewer.html


# 動かす前の準備

まずは、AWS Kinesis Video Streamsを使う想定で、シグナリングチャネル名を作成しておきます。

AWS Kinesis Video Streams

https://ap-northeast-1.console.aws.amazon.com/kinesisvideo/home?region=ap-northeast-1#/signalingChannels

名前を決めるだけで作成できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/00d5ce5c-fcbd-2752-9b7e-0db56d3bc6b0.png)

# 動かしてみる

まずは、WebRTCのマスター側で、配信を開始しておきます。

https://poruruba.github.io/AWSKVS_WebRTC/index_webcam.html

シグナリングチャネル名は、先ほど作成した「Room1」「Room2」「Room3」のいづれかにしておきましょう。

詳細は以下の通り。
　[AWS Kinesis Video StreamsでMMDをWebRTCで配信する]( https://qiita.com/poruruba/items/7a52d5f01f0f46ec3c63)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/35b3499f-d018-d494-2462-bf3e3f71a08d.png)

Start Masterボタンを押すと。。。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/3f1494e7-65d8-ac06-7941-6aa7242b5596.png)

それではさっそくスマートディスプレイで動かしてみましょう。
Nest Hubに以下を言います。
「OK、Google。テストキャンバスにつないで」
Actions on Googleの設定で、Display nameに指定した名前です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/51239cdd-6a57-6254-f291-0b05d753a0ea.png)
 
上記は、エミュレータで表示したときの画像ですが、本物のスマートディスプレイやスマートフォンでもちゃんと表示されます。

ここで、「StartViewer」ボタンを押下すると、めでたく映像配信を受信できるかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e0b43c57-ed13-3025-cf52-80678b130b98.png)

ちなみに、Androidからも、「OK、Google。テストキャンバスにつないで」と言えば、同じように動きます。

以上