ChromecastとGoogleHomeで声だけで操作できるブラウザを作る

# 背景

ふと湧いてきた疑問にスマートスピーカーが答えてくれずもどかしい思いをすることってよくありますよね。
スマートスピーカーの横にテレビがあるので、テレビに映ったブラウザを声だけで操作できたら面白いかなと思って作ってみました。

# アプケーション仕様

テレビに映したいので`Chromecast`を利用します。(テレビはHDMI-CEC対応 Chromecastから電源のON/OFFができます。)
音声指示には`Google Home`を利用します。

## 構成

### 構成イメージ
![Untitled Diagram.jpg](https://qiita-image-store.s3.amazonaws.com/0/265837/310905e5-9a2a-818b-860a-80131a65e608.jpeg)

### 動作イメージ

- (1) 音声で検索キーワードなどを`Google Home`へ指示
- (2) `Google Home`から`IFTTT`へ連携
- (3) `IFTTT`からの`今回作成のアプリ`の呼び出し
- (4) `今回作成のアプリ`がブラウザ(`Google chrome`)を操作
- (5) `Chromecast`へキャスト
- (6) `今回作成アプリ`から`Chromecast`へブラウザ画面の`Motion JPEG`をストリーミング
- (7) ブラウザ画面の`Motion JPEG`を`TV`へ映す

### 主な機材・サービスと役割

| 名前 | 役割 | 
|:------|:----|
| Google Home | 音声指示 |
| Chromecast | ブラウザ画面表示 |
| Google Chrome | 今回作成のアプリより操作される |
| IFTTT | `Google Home` と `今回作成のアプリ`間の連携 |
| 今回作成のアプリ | ブラウザ操作、ブラウザ画面のストリーミング |

## 開発環境

- Windows 10 Pro
- Node.js v8.9.2

## 実装機能

今回は、実装を簡単にするために、以下のややプリミティブなコマンドだけを実装します。

- タブキー入力(フォーカス移動)
- 文字入力(テキスト領域への文字入力)
- エンターキー(リンク移動、ボタン等のクリック、検索　等)

実装を簡単にするために、以下に対応しません。

- LAN内にChromecastが複数ある場合
- ChromecastのIPアドレスが変更される場合
- セキュリティに関わるところ

# アプリの実装

## 1.Web APIで操作できるブラウザを作る

動作イメージ(4)の機能を実装します。
headlessブラウザならなんでもよいので、`chrome`を`selenium-webdriver`より操作する実装としました。(seleniumは本来の用途とは違うような気もしますが、楽するために目をつむります)
動作させるにはChromeのWebDriverをパスが通った場所へ配置する必要があります。
(ダウンロード [ChromeDriver - WebDriver for Chrome](http://chromedriver.chromium.org/downloads))

```javascript
const fs = require('fs');
const express = require('express');
const { Capabilities, Condition, Builder, By, Key } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');

const browserWidth = 1024;
const browserHeight = 768;

let app = express();
app.listen(3000);

//ヘッドレスでドライバを作成
let driver = new Builder()
    .withCapabilities(Capabilities.chrome())
    .setChromeOptions(new chrome.Options().headless().windowSize({width : browserWidth, height : browserHeight}))
    .build();

//ページロード完了待ち用のCondition
let pageLoadCondition = new Condition("wait for ready", function (driver) {
    return driver.executeScript('return document.readyState;').then(state => state == 'complete');
});

//Googleをとりあえずは表示
driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));

app.get("/api/ctl", async function (req, res, next) {
    console.log(req.query);

    //クエリパラメーター取得
    //c : コマンド, v : 検索キーワード
    let command = req.query['c'];
    let value = req.query['v'];

    if (command == null) {
        res.send('error');
        return;
    }

    let element = await driver.switchTo().activeElement();

    if (command == 'search') {
        await driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));
        if (value != null) {
            await driver.findElement(By.name('q')).sendKeys(value, Key.RETURN);
        }
    } else {
        if (command == 'tab') {
            await element.sendKeys(Key.TAB);
        } else if (command == 'key') {
            await element.sendKeys(value);
        } else if (command == 'enter') {
            await element.sendKeys(Key.ENTER);
        } else {
            res.send('unknown command');
            return;
        }
    }

    //ページのロード完了後にスクリーンショットを保存
    await driver.wait(pageLoadCondition);
    await driver.takeScreenshot().then(image => fs.writeFileSync('out.png', image, 'base64'));

    res.send('OK');
});
```

下記のURLへアクセスすると、ブラウザが操作され、スクリーンショットが`out.png`ファイルに出力されます。

```
http://localhost:3000/api/ctl?c=search&value=test
http://localhost:3000/api/ctl?c=tab
http://localhost:3000/api/ctl?c=key&value=test
http://localhost:3000/api/ctl?c=enter
```

## 2.ブラウザの画面をキャストする仕掛けを作る

動作イメージの(4)と(6)の`Motion JPEG`をストリーミングする機能を実装します。
`Chromecast`へ単発で画像をキャストすると、体感5秒ほどのタイムラグがあります。
ネットワークカメラのストリーミングと同じように`multipart/x-mixed-replace`で`Motion JPEG`を`Chromecast`へキャストすることで、少ないタイムラグで画面が表示されるよう実装してみました。

### multipart/x-mixed-replaceの動作イメージ

`Mixed-Replace`の動作は以下のようなイメージです。
Jpeg画像が、1秒に1回 延々とキャストされ、動画のように表示されます。

*リクエストヘッダ*

```http
GET /image HTTP/1.1
[他リクエストヘッダ省略]
```

*レスポンス(続く)*

```http
HTTP/1.1 200 OK
Content-type:multipart/x-mixed-replace; boundary=--myboundary
Connection:keep-alive
[他レスポンスヘッダ省略]

```

*レスポンスの続き 以下を延々と繰り返し*

```http
Content-Type:image/jpeg

[JPEGファイルの中身]

--myboundary

```

### ソースの実装

```javascript
const fs = require('fs');
const express = require('express');
const { Capabilities, Condition, Builder, By, Key } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');

const browserWidth = 1024;
const browserHeight = 768;
const framePerMilliseconds = 1000;

let imagebuf = null;

let app = express();
app.listen(3000);

//ヘッドレスでドライバを作成
let driver = new Builder()
    .withCapabilities(Capabilities.chrome())
    .setChromeOptions(new chrome.Options().headless().windowSize({ width: browserWidth, height: browserHeight }))
    .build();

//ページロード完了待ち用のCondition
let pageLoadCondition = new Condition("wait for ready", function (driver) {
    return driver.executeScript('return document.readyState;').then(state => state == 'complete');
});

//Googleをとりあえずは表示
driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));

//ブラウザ操作処理
app.get("/api/ctl", async function (req, res, next) {
    console.log(req.query);

    //クエリパラメーター取得
    //c : コマンド, v : 検索キーワード
    let command = req.query['c'];
    let value = req.query['v'];

    if (command == null) {
        res.send('error');
        return;
    }

    let element = await driver.switchTo().activeElement();

    if (command == 'search') {
        await driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));
        if (value != null) {
            await driver.findElement(By.name('q')).sendKeys(value, Key.RETURN);
        }
    } else {
        if (command == 'tab') {
            await element.sendKeys(Key.TAB);
        } else if (command == 'key') {
            await element.sendKeys(value);
        } else if (command == 'enter') {
            await element.sendKeys(Key.ENTER);
        } else {
            res.send('unknown command');
            return;
        }
    }

    //ページのロード完了後にスクリーンショットを保存
    await driver.wait(pageLoadCondition);
    await driver.takeScreenshot().then(image => imagebuf = new Buffer(image, 'base64'));

    res.send('OK');
});

//ストリーミング処理
app.get("/image", function (req, res) {

    var closed = false;

    //クライアントの切断検知
    req.on('close', _ => closed = true);

    //スクリーンショットなしの状態は Not Found
    if (imagebuf == null) {
        res.status(404).send("");
        return;
    }

    res.setHeader('Content-type', 'multipart/x-mixed-replace; boundary=--myboundary');

    framefunc = function () {
        //クライアントコネクションが切れている場合は終了する
        if (closed) {
            return;
        }

        //JPEG画像を出力
        res.write("Content-Type: image/jpeg\r\n\r\n");
        res.write(imagebuf);
        res.write("\r\n--myboundary\r\n");
        setTimeout(framefunc, framePerMilliseconds);
    }
    setTimeout(framefunc, framePerMilliseconds);
});
```

下記URLをブラウザで表示すると、操作されている方のブラウザの画面が`Motion JPEG`で表示されます。

```
http://localhost:3000/image
```
## 3.Chromecastのディスカバリ

動作イメージ(5)の一部の機能としてキャスト対象をディスカバリする機能を実装します。
LAN内の`Chromecast`は`Multicast DNS`というプロトコルを用いてディスカバリできます。
nodejsでは`mdns`がありますが、ネイティブモジュールへの依存がありますので、今回は導入を簡単にするためにピュアJavaScriptで実装された`mdns-js`を利用してディスカバリを実装します。

`mDNS-js`のexampleを実行してみます。
(引用元 : [mdns-js - npm](https://www.npmjs.com/package/mdns-js))

```javascript
var mdns = require('mdns-js');
//if you have another mdns daemon running, like avahi or bonjour, uncomment following line
//mdns.excludeInterface('0.0.0.0');
 
var browser = mdns.createBrowser();
 
browser.on('ready', function () {
    browser.discover(); 
});
 
browser.on('update', function (data) {
    console.log('data:', data);
});
```

以下のような情報が出力されます。
※抜粋、一部機器の固有情報と思われる個所は書き換えています。

```
data: { addresses: [ '192.168.1.5' ],
  query: [],
  type:
   [ { name: 'googlecast',
       protocol: 'tcp',
       subtypes: [],
       description: 'Google Chromecast' } ],
  txt:
   [ 'id=1d7ddcf68a9c339d2580c2724cce2860',
     'cd=AFD8D7BDD577A95677AAE143F145E35C',
     'rm=1DFCE12E2C6EE876',
     've=05',
     'md=Google Home Mini',
     'ic=/setup/icon.png',
     'fn=浴室',
     'ca=2052',
     'st=0',
     'bs=FA8FCA67E821',
     'nf=1',
     'rs=' ],
  port: 8009,
  fullname: 'Google-Home-Mini-1d7ddcf68a9c339d2580c2724cce2860._googlecast._tcp.local',
  host: '1d7ddcf6-8a9c-339d-2580-c2724cce2860.local',
  interfaceIndex: 2,
  networkInterface: 'pseudo multicast' }
data: { addresses: [ '192.168.1.8' ],
  query: [],
  type:
   [ { name: 'googlecast',
       protocol: 'tcp',
       subtypes: [],
       description: 'Google Chromecast' } ],
  txt:
   [ 'id=0a309e320bd99823e844868a2b0cfe74',
     'cd=DE675BE855482A9DF0199D0CBC523894',
     'rm=330BBCF625639982',
     've=05',
     'md=Google Home',
     'ic=/setup/icon.png',
     'fn=リビング',
     'ca=2052',
     'st=0',
     'bs=FA8FCB5112987',
     'nf=1',
     'rs=' ],
  port: 8009,
  fullname: 'Google-Home-0a309e320bd99823e844868a2b0cfe74._googlecast._tcp.local',
  host: '0a309e32-0bd9-9823-e844-868a2b0cfe74.local',
  interfaceIndex: 2,
  networkInterface: 'pseudo multicast' }
data: { addresses: [ '192.168.1.9' ],
  query: [],
  type:
   [ { name: 'googlecast',
       protocol: 'tcp',
       subtypes: [],
       description: 'Google Chromecast' } ],
  txt:
   [ 'id=3e882e857cce09a7a7eb98708ac3ec27',
     'cd=02E013304A6EA91865369767A46B062C',
     'rm=F738A23B4FAC53CB',
     've=05',
     'md=Chromecast',
     'ic=/setup/icon.png',
     'fn=リビング 3',
     'ca=4101',
     'st=0',
     'bs=FA8FCA30AC15',
     'nf=1',
     'rs=' ],
  port: 8009,
  fullname: 'Chromecast-3e882e857cce09a7a7eb98708ac3ec27._googlecast._tcp.local',
  host: '3e882e85-7cce-09a7-a7eb-98708ac3ec27.local',
  interfaceIndex: 2,
  networkInterface: 'pseudo multicast' }
```

LAN内には、`Google Home`、`Google Home Mini`、`Chromecast`があります。
今回は、Google HomeとChromecastを簡単に判別するために、`fullname`の先頭が`Chromecast`の文字列で始まるという条件で簡易的に`Chromecast`を識別することにします。

```javascript
var mdns = require('mdns-js');
 
var browser = mdns.createBrowser();

var chromecastip = null;

browser.on('ready', function () {
    browser.discover(); 
});

browser.on('update', function (data) {
    if(chromecastip == null 
        && data.fullname != null
        && data.fullname.startsWith("Chromecast")){
        chromecastip = data.addresses[0];
        console.log('chromecast : ' + chromecastip);
    }
});
```

## 4.Chromecastへの画面表示

動作イメージ(5)を実装します。
ブラウザ画面をストリーミングすることはできるようになりましたので、次は`Chromecast`へキャストする部分を実装します。
`castv2-client`を利用してLAN内の`Chromecast`を検知します。
ブラウザの操作が行われると`Chromecast`へブラウザ画面の`Motion JPEG`をキャストします。

今回の開発機はWindowsです。ネイティブモジュールのビルドは避けたいので以下のコマンドでモジュールをインストールします。

```bash
npm install castv2 --no-optional
```

`castv2-client`のexampleを少し修正して、`Chromecast`に画像を表示してみます。
(引用元 [castv2-client](https://www.npmjs.com/package/castv2-client))

```javascript
const Client = require('castv2-client').Client;
const DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver;
const mdns = require('mdns-js');

var client = new Client();
var browser = mdns.createBrowser(mdns.tcp('googlecast'));

var chromecastip = null;

browser.on('ready', function () {
    browser.discover();
});

browser.on('update', async function (data) {
    if (chromecastip == null
        && data.fullname != null
        && data.fullname.startsWith("Chromecast")) {

        chromecastip = data.addresses[0];
        console.log('chromecast : ' + chromecastip);

        client.connect(chromecastip, function () {
            client.launch(DefaultMediaReceiver, function (err, player) {
                var media = {
                    contentId: 'https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png',
                    contentType: 'image/png',
                    streamType: 'LIVE'
                };
                player.load(media, { autoplay: true }, function (err, status) {
                    client.close();
                });
            });
        });
    }
});
```

`HDMI-CEC`のおかげで、テレビが消えていても、別の入力になっていても切り替わって画像が表示されました。

## 5.アプリを完成させる

動作イメージ(4)～(6)を実装します。
これまでのソースを合わせてアプリを完成させます。
ブラウザで前述のURLを開くと、`Chromecast`にブラウザの画面が表示されます。

完成版ソース

```javascript
const os = require('os');
const express = require('express');

const { Capabilities, Condition, Builder, By, Key } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');

const mdns = require('mdns-js');
const { Client } = require('castv2-client');
const {DefaultMediaReceiver} = require('castv2-client');

const browserWidth = 1024;
const browserHeight = 768;
const framePerMilliseconds = 1000;

let imagebuf = null;

const browser = mdns.createBrowser(mdns.tcp('googlecast'));
var chromecastip = null;

browser.on('ready', function () {
    browser.discover();
});

browser.on('update', async function (data) {
    if (chromecastip == null
        && data.fullname != null
        && data.fullname.startsWith("Chromecast")) {

        chromecastip = data.addresses[0];
        console.log('chromecast : ' + chromecastip);
    }
});

let app = express();
app.listen(3000);

//ローカルIP取得
let interfaces = os.networkInterfaces();
let localaddress = Array.prototype.concat(Object.values(interfaces).reduce((a, b) => a.concat(b)))
    .find((a)=> !a.internal && a.family == 'IPv4');

//ヘッドレスモードでドライバを作成
let driver = new Builder()
    .withCapabilities(Capabilities.chrome())
    .setChromeOptions(new chrome.Options().headless().windowSize({ width: browserWidth, height: browserHeight }))
    .build();

//ページロード完了待ち用のCondition
let pageLoadCondition = new Condition("wait for ready", function (driver) {
    return driver.executeScript('return document.readyState;').then(state => state == 'complete');
});

//Googleをとりあえずは表示
driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));

//ブラウザ操作処理
app.get("/api/ctl", async function (req, res, next) {
    console.log(req.query);

    //クエリパラメーター取得
    //c : コマンド, v : 検索キーワード
    let command = req.query['c'];
    let value = req.query['v'];

    if (command == null) {
        res.send('error');
        return;
    }

    let element = await driver.switchTo().activeElement();

    if (command == 'search') {
        await driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));
        if (value != null) {
            await driver.findElement(By.name('q')).sendKeys(value, Key.RETURN);
        }

        let client = new Client();

        //検索時にキャストする
        client.connect(chromecastip, function () {
            client.launch(DefaultMediaReceiver, function (err, player) {
                var media = {
                    contentId: 'http://' + localaddress.address + ':3000/image',
                    contentType: 'image/jpeg',
                    streamType: 'LIVE'
                };

                console.log(media);

                player.load(media, { autoplay: true }, function (err, status) {
                    client.close();
                    console.log('closed');
                });

            });
        });
        client.on('error', function(err) {
            console.log('Error: %s', err.message);
            client.close();
        });
                
    } else {
        if (command == 'tab') {
            await element.sendKeys(Key.TAB);
        } else if (command == 'key') {
            await element.sendKeys(value);
        } else if (command == 'enter') {
            await element.sendKeys(Key.ENTER);
        } else {
            res.send('unknown command');
            return;
        }
    }

    //ページのロード完了後にスクリーンショットを保存
    await driver.wait(pageLoadCondition);
    await driver.takeScreenshot().then(image => imagebuf = new Buffer(image, 'base64'));

    res.send('OK');
});

//ストリーミング処理
app.get("/image", function (req, res) {

    var closed = false;

    //クライアントの切断検知
    req.on('close', _ => closed = true);

    //スクリーンショットなしの状態は Not Found
    if (imagebuf == null) {
        res.status(404).send("");
        return;
    }

    res.setHeader('Content-type', 'multipart/x-mixed-replace; boundary=--myboundary');

    framefunc = function () {
        //クライアントコネクションが切れている場合は終了する
        if (closed) {
            return;
        }

        //JPEG画像を出力
        res.write("Content-Type: image/jpeg\r\n\r\n");
        res.write(imagebuf);
        res.write("\r\n--myboundary\r\n");
        setTimeout(framefunc, framePerMilliseconds);
    }
    setTimeout(framefunc, framePerMilliseconds);
});
```

## 6.Google Homeとアプリ間の連携(IFTTT)

動作イメージ(3)の設定を行います。
`IFTTT`の`Webhook`を使って、今回作成のアプリと`Google Home`を連携します。

### コマンド

| コマンド(ウェイクワードを除く) | ブラウザ操作 |
|:------|:---------|
| ブラウザ検索 [キーワード]|検索|
|ブラウザ エンター| エンターキーでボタン押下やリンク移動 |
|ブラウザ タブ | タブキーでフォーカス移動 |

### 外部からのアクセス準備

まずは準備として、`ngrok`を利用して`IFTTT`から`今回作成のアプリ`への一時的なアクセスを許可します。
(参考 [ngrokが便利すぎる](https://qiita.com/mininobu/items/b45dbc70faedf30f484e))

httpプロトコルで、ポート3000へのフォワーディングを設定します。

```bash
> ngrok http 3000

ngrok by @inconshreveable                                       (Ctrl+C to quit)

Session Status                online
Session Expires               7 hours, 49 minutes
Version                       2.2.8
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://1ab3c4ec.ngrok.io -> localhost:3000
Forwarding                    https://1ab3c4ec.ngrok.io -> localhost:3000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              2       0       0.00    0.00    6.47    7.93

HTTP Requests
-------------

GET /api/ctl                   200 OK
```

### IFTTTの設定

#### Google Assistant(Say a phrase with a text ingradient) の設定

Google Home からの音声コマンドを受ける IFTTTの `this` にあたる部分を設定します。
「OK Google ブラウザ検索 ミスターメンリトルミス」と指定された場合は、下記の`$`にあたる`ミスターメンリトルミス`が後続の`that`に該当する`webhook`に渡されます。

|項目|設定|
|---|---|
|What do you want to say?|ブラウザ検索 $|
|What's another way to say it?||
|And another way?||
|What do you want the Assistant to say in response?||
|Language|Japanese|

#### Webhook(Make a web request) の設定

前述の`this`にあたる`Google Assistant`からのキーワードを受けて、`今回作成するアプリ`のWebAPIへ指示を出します。

注意点としましては、クエリストリングはURLエンコードする必要があるため`<<<`と`>>>`で囲む必要があります。
(参考 [IFTTTでif `Google Assistant` then `Webhook`でBad Requestが発生したときの対応メモ](https://qiita.com/n-noguchi/items/cc1c53e59e652cd0b93e)

|項目|設定|
|---|---|
|URL|http://1ab3c4ec.ngrok.io/api/ctl?c=search&v=<<<{{TextField}}>>>|
|Method|GET|
|Content Type|text/plain|
|Body||

*他コマンドはURLが違うだけの登録なので、割愛します。

Google Homeへ指示を出すと、ブラウザの検索結果がテレビに出力されました。
![DSC_0020.png](https://qiita-image-store.s3.amazonaws.com/0/265837/95d92e6a-a4fd-88d0-f564-39c71ddb5007.png)


# 使用感

## 良かったところ

- 手ぶらでブラウザが操作できました。
- テレビの電源が入っていなくても、HDMI-CECのおかげで検索時に自動で電源が入っていい感じです。
- `OK Google TV消して`で着いたテレビを後始末できます。

## 改良の余地があるところ

- 1操作事に毎回ウェイクワードを言わないならないため大変です。
- 1操作の度に`Google Home`が`OK アクションを実行します。`と返事してしまいます。
- googleの検索結果画面は、タブキーでのフォーカス遷移を十数回行わないと検索結果のリンクに到達できず喉が痛くなります。
- 同音異義語は検索が難しいです。`speech to text`と、googleの`もしかして？`検索次第
- `I'm feeling lucky`機能があれば使い勝手がよくなるかもしれません。

# まとめ

まずは動くということろを優先したので手抜きではありますが、音声操作でWebページを検索できるというところは実現はできました。
Webページを検索するという目的には、キー操作　や　フォーカス移動　のような間接的な操作は余計なものでした。
実用的にするには、よくあるスマートスピーカーの操作例に習い　検索で第一候補を表示、次ページ、次ページと候補を表示できるのがベターです。

テレビに家族のカレンダーを映して、予定を音声で登録とかそんなユースケースも面白そうだと思いました。

なんだかんだ言っていろんな技術やモノを組合せて楽しめました。