背景
ふと湧いてきた疑問にスマートスピーカーが答えてくれずもどかしい思いをすることってよくありますよね。
スマートスピーカーの横にテレビがあるので、テレビに映ったブラウザを声だけで操作できたら面白いかなと思って作ってみました。
アプケーション仕様
テレビに映したいのでChromecast
を利用します。(テレビはHDMI-CEC対応 Chromecastから電源のON/OFFができます。)
音声指示にはGoogle Home
を利用します。
構成
構成イメージ
動作イメージ
- (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)
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回 延々とキャストされ、動画のように表示されます。
リクエストヘッダ
GET /image HTTP/1.1
[他リクエストヘッダ省略]
レスポンス(続く)
HTTP/1.1 200 OK
Content-type:multipart/x-mixed-replace; boundary=--myboundary
Connection:keep-alive
[他レスポンスヘッダ省略]
レスポンスの続き 以下を延々と繰り返し
Content-Type:image/jpeg
[JPEGファイルの中身]
--myboundary
ソースの実装
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)
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
を識別することにします。
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です。ネイティブモジュールのビルドは避けたいので以下のコマンドでモジュールをインストールします。
npm install castv2 --no-optional
castv2-client
のexampleを少し修正して、Chromecast
に画像を表示してみます。
(引用元 castv2-client)
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
にブラウザの画面が表示されます。
完成版ソース
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が便利すぎる)
httpプロトコルで、ポート3000へのフォワーディングを設定します。
> 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が発生したときの対応メモ
項目 | 設定 |
---|---|
URL | http://1ab3c4ec.ngrok.io/api/ctl?c=search&v=<<<{{TextField}}>>> |
Method | GET |
Content Type | text/plain |
Body |
*他コマンドはURLが違うだけの登録なので、割愛します。
Google Homeへ指示を出すと、ブラウザの検索結果がテレビに出力されました。
使用感
良かったところ
- 手ぶらでブラウザが操作できました。
- テレビの電源が入っていなくても、HDMI-CECのおかげで検索時に自動で電源が入っていい感じです。
-
OK Google TV消して
で着いたテレビを後始末できます。
改良の余地があるところ
- 1操作事に毎回ウェイクワードを言わないならないため大変です。
- 1操作の度に
Google Home
がOK アクションを実行します。
と返事してしまいます。 - googleの検索結果画面は、タブキーでのフォーカス遷移を十数回行わないと検索結果のリンクに到達できず喉が痛くなります。
- 同音異義語は検索が難しいです。
speech to text
と、googleのもしかして?
検索次第 -
I'm feeling lucky
機能があれば使い勝手がよくなるかもしれません。
まとめ
まずは動くということろを優先したので手抜きではありますが、音声操作でWebページを検索できるというところは実現はできました。
Webページを検索するという目的には、キー操作 や フォーカス移動 のような間接的な操作は余計なものでした。
実用的にするには、よくあるスマートスピーカーの操作例に習い 検索で第一候補を表示、次ページ、次ページと候補を表示できるのがベターです。
テレビに家族のカレンダーを映して、予定を音声で登録とかそんなユースケースも面白そうだと思いました。
なんだかんだ言っていろんな技術やモノを組合せて楽しめました。