14
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

古いiPadを使ってAlexa,Google Assistant連動スマートディスプレイを作る

Last updated at Posted at 2019-01-13

ご注意

  • この記事は、あるスマートスピーカー並行動作芸人の平凡なお遊びを淡々と描く物です。過度な期待はしないでください。

  • スマートディスプレイが欲しい人は、この記事に従ってあれこれするより、お金を出して買った方が100%幸せになれます。これぞと思ったものを買いましょう。

  • メカニズムと作ってみた事実を紹介する程度のネタなので、最後まで読んでも、この記事だけではスマートディスプレイは多分作れません。

    • 筆者のやる気が出たら、リソースを共有します。

はじめに

 昨年末、iPad Pro 2018(11インチ)を買ったんですよ(ドヤ顔)。

 これと交代させる形で、普段使いしていたタブレットを引退させたんですが、家の中を見渡すと、使用済みの古いタブレットがたくさんあってですね…。

  • iPad(初代)

  • iPad2

  • Nexus 7

Nexus7は別途用途があったのでさておき、iOSの度重なるアップデートで、実用レベルで動かなくなってしまった古いiPadをどうしようかなぁと。

サブディスプレイとして使う手もあったんですが、その方向を追求していったら、何やら面白いものができてしまったので、ここでシェアしてみようかと思います。

どんなの作ったの?

記事を書く前にようつべにリンクしておいたので、ご覧いただいた方もいらっしゃるかと思いますが。

  • iPad 2 smart display powered by Alexa and Google Assistant

iPad 2 smart display powered by Alexa and Google Assistant

料理のレシピやらなにやら、という高等な機能はできないんですが、

  • Alexa/Google Assistantの両アシスタントを呼び出せる
    • Alexaは天気予報と簡単な質問に対してディスプレイ表示
    • Google AssistantはHTML5レスポンスで出力される内容をそのまま出力
  • Wakeword認識後、音声入力待ちAlertを表示
    • Wakeword以降、命令を入れないと元に戻る

という、音声操作可能なディスプレイとして最小限の機能を実現しています。

ついでに日付時刻/(別系統から入力した現在の天気)/ニュースフィードを上部に表示し、背景とともに自動更新しています。

種明かし

ようつべでも最初に言っているんですが、iPad単体ではこのメカニズムを実現できなくてですね。

賢明なる読者諸兄はお察しの通り、こんなものが裏では動いているわけです。

IMG_0056.JPG

あと、よく見ると画面上部に謎のアイコンが2つあるかと思います。

これ、Windowsリモートデスクトップアプリが投影した画面なんです。つまりこの画面はiPad以外の画面表示をリモート投影しているわけです。

ただし、ここまでの情報から『Raspberry Pi3の画面をiPadにリモートデスクトップ表示している』という判断は早計で。

Raspberry Pi3の画面をそのまま投影すると、CPU/GPU性能がボトルネックになって、描画が遅くなってしまうのです。

というわけで、このシステムの構成図を示してみます。

構成図

SmartDisplay_Diagram.png

試行錯誤していたら、この構成でできた、ってだけなので、アーキテクチャとしてはだいぶ改良の余地がある 1と思いますが、大体こんな感じです。

図中の番号順で処理が動きます。

材料

材料としてはこんな感じ。

 AlexaとGoogle Assistantの並行動作については、以下ですでに実現済みのメカニズムを使っているので、ここでは触れません。

  • Pi Zeroでwake word付AlexaとGoogle Assistantを並行動作させる(With ReSpeaker)

 簡単なところから個別に説明していきましょうか。

MagicMirrorの構成

iPadの描画に限ってだけ言うと、描画用サーバの画面をリモートで表示している、というメカニズムがまず根本にあるわけですが、描画用サーバ側の画面表示にはMagicMirrorを使っています。

当初、サイネージ的な使い方を意識していたので、MagicMirrorからiPadに描画するだけでも、それっぽく表示できるんですが、ふと思ったんですよ。

  • 両Assistantsが動いているRaspberry Piを準備しつつ、
  • そのRaspberry Piを、描画用サーバの画面を投影しているiPadの近くに置いといて、
  • Raspberry PiからHTTP経由でMagicMirrorにNotificationを飛ばせば、
  • あたかもiPadがAssistantを動かしているように見せられるのではないか?

 そこで、すでに両Assistantを並行動作させているAssistantsに手を入れて、Assistantの状態変化に応じて、MagicMirrorにHTTPリクエストを飛ばせるようにしてみたんですよ。

 MagicMirror側ではMMM-APIで受けて、受信したメッセージをそのままAlertに表示させるようにします。その結果がこれ。

  • Alexa & Google Assistant with iPad 2 !?(Proof of Concept)

 Alexa & Google Assistant with iPad 2 !?(Proof of Concept)

Assistant出力のビジュアル化

これで気をよくした私は、

  • Assistantの情報だけじゃなく、応答内容を出力できたら、古いiPadを有効活用してスマートディスプレイを手作りできるのでは?

と考えはじめまして。

そうなると、考えなければならないことは2つあります。

  1. Alexa/Google Assistantから、どのように画面出力を得るか?
  2. Assistantから得た画面出力を、どのようにMagicMirrorに表示させるか?

Alexa/Google Assistantから、どのように画面出力を得るか?

 スマートスピーカー的にはここが肝ですね。

 実は結構前から、Alexa/Google Assistantともに、画面表示出力を得る方法があったりします。

Alexa

 Alexaに対して一定のリクエストをすると、AVSは応答を音声だけではなく、JSONデータとして返してくれるという機能があるのです。

 ただ、この機能を使うには、Alexa音声サービス開発者コンソールから、『製品』を選び、対象製品の能力として、『カードを表示する』を有効にしてやる必要があります。

DisplayCard_Alexa.png

 この機能を有効にすると、例えば天気を聞いたとき、こんなJSONが返ってくるんですよ。

{
    "type":"WeatherTemplate",
    "token":"0c0c49a7-74cc-4229-8786-a3f022fa6d62",
    "lowTemperature": {
        "value":"0°",
        "arrow":{
            "contentDescription":"Down arrow",
            "sources":[
                {
                    "widthPixels":88,"darkBackgroundUrl":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/small/currentWeatherIcon/down_bb.png",
                    "size":"SMALL",
                    "heightPixels":80,"url":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/small/currentWeatherIcon/down.png"
                },
                {
                    "widthPixels":256,"darkBackgroundUrl":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/medium/currentWeatherIcon/down_bb.png",
                    "size":"MEDIUM",
                    "heightPixels":233,"url":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/medium/currentWeatherIcon/down.png"
                },
                {
                    "widthPixels":305,"darkBackgroundUrl":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/large/currentWeatherIcon/down_bb.png",
                    "size":"LARGE",
                    "heightPixels":278,"url":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/large/currentWeatherIcon/down.png"
                },
                {
                    "widthPixels":564,"darkBackgroundUrl":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/extralarge/currentWeatherIcon/down_bb.png",
                    "size":"X-LARGE",
                    "heightPixels":530,"url":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/extralarge/currentWeatherIcon/down.png"
                }
            ]}
    },
    "title":{
        "subTitle":"2019年1月14日月曜日",
        "mainTitle":"日本千代田"
    },
    "currentWeatherIcon":{
        "contentDescription":"Sunny",
        "sources":[
            {
                "widthPixels":88,
                "darkBackgroundUrl":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/small/currentWeatherIcon/sunny_bb.png",
                "size":"SMALL",
                "heightPixels":80,
                "url":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/small/currentWeatherIcon/sunny.png"
            },
            {
                "widthPixels":256,
                "darkBackgroundUrl":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/medium/currentWeatherIcon/sunny_bb.png",
                "size":"MEDIUM",
                "heightPixels":233,
                "url":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/medium/currentWeatherIcon/sunny.png"
            },
            {
                "widthPixels":305,
                "darkBackgroundUrl":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/large/currentWeatherIcon/sunny_bb.png",
                "size":"LARGE",
                "heightPixels":278,
                "url":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/large/currentWeatherIcon/sunny.png"
            },
            {
                "widthPixels":564,
                "darkBackgroundUrl":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/extralarge/currentWeatherIcon/sunny_bb.png",
                "size":"X-LARGE",
                "heightPixels":530,
                "url":"https://images-na.ssl-images-amazon.com/images/G/01/alexa/avs/gui/extralarge/currentWeatherIcon/sunny.png"}
            ]},
    ……

このJSONは、Sample Appではそのまま標準出力には出てきません。Sample Appでこれを取り扱いたい場合は、GuiRenderer.cpp内のrenderTemplateCard第一引数のjsonPayloadを拾ってやる必要があります。

void GuiRenderer::renderTemplateCard(const std::string& jsonPayload, avsCommon::avs::FocusState focusState) 

renderTemplateCard内では、受け取ったJSONを、RapidJSONとAVS Device SDKの独自関数で解析していて、簡単な情報表示はrenderTemplateCard内で実施されています。実際、タイトル程度はこんな感じで取り出され、画面に出てきます。

##############################################################################
#     RenderTemplateCard                                                      
#-----------------------------------------------------------------------------
# Focus State         : FOREGROUND
# Template Type       : WeatherTemplate
# Main Title          : 日本千代田
##############################################################################

ということは、このJSONを画面表示可能な情報にレンダリング、端的に言えばHTMLに変換してやればいいわけです。

変換に当たっては、JSONに含まれるTemplate Typeに気を使う必要があります。Template Typeごとに含まれている情報が違いますからね。

JSON→HTML変換というと、JavaScriptあたりを使うのが普通なのかもしれませんが、面倒なのでC++上で作ってみることにしました。

まだ未整理の汚いコードですが、例えばBodyTemplate2を変換するときにはこんな感じでメソッドを作って、renderTemplateCardから呼び出すことで、Assistant応答時に所定のフォルダにHTMLファイルを書き出すようにします。

void GuiRenderer::OutputBodyTemplate2(const std::string& jsonPayload, const rapidjson::Document& payload,avsCommon::avs::FocusState focusState){

    rapidjson::Value::ConstMemberIterator titleNode;
    if (!jsonUtils::findNode(payload, TITLE_NODE, &titleNode)) {
        ConsolePrinter::simplePrint("ERROR: Template JSON payload has no title field");
        return;
    }

    std::string mainTitle;
    if (!jsonUtils::retrieveValue(titleNode->value, MAIN_TITLE_TAG, &mainTitle)) {
        ConsolePrinter::simplePrint("ERROR: Template JSON payload has no main title field");
        return;
    }

    std::string textField;
    if (!jsonUtils::retrieveValue(payload, std::string("textField"), &textField)) {
        ConsolePrinter::simplePrint("ERROR: Template JSON payload has no text field");
        return;
    }

    rapidjson::Value::ConstMemberIterator imageNode;
    if (!jsonUtils::findNode(payload, std::string("image"), &imageNode)) {
        ConsolePrinter::simplePrint("ERROR: Template JSON payload has no image field");
        return;
    }

    rapidjson::Value::ConstMemberIterator imageSourceNode;
    if (!jsonUtils::findNode(imageNode->value, std::string("sources"), &imageSourceNode)) {
        ConsolePrinter::simplePrint("ERROR: Template JSON payload has no imageSource field");
        return;
    }

    std::string url;
    if (!jsonUtils::retrieveValue(imageSourceNode->value[0], std::string("url"), &url)) {
        ConsolePrinter::simplePrint("ERROR: Template JSON payload has no url field");
        return;
    }

    std::string html;

    html += "<html>\n";
    html += "<head><meta charset=\"utf-8\"></head>\n";
    html += "<body bgcolor=\"white\">";
    html += "<h1>" + mainTitle + "</h1></br>";
    html += "<table>";
    html += "<tr><td>";
    html += textField + "<br>";
    html += "</td></tr>";
    html += "<tr><td>";
    html += "<img src=\"" + url + "\"/>";
    html += "</td>";
    html += "</tr>";
    html += "</table>";

    html += "<div align=\"right\"><img src=\"https://images-na.ssl-images-amazon.com/images/G/01/mobile-apps/dex/avs/docs/ux/branding/mark3._TTH_.png\" /></div>";
    html += "</body>\n";
    html += "</html>\n";

    std::ofstream HTMLFile("/var/www/html/assistant/index.html");
    HTMLFile << html;
    HTMLFile.close();
}

で、Alexaに『Amazonについて教えて』とか聞くと、上記メソッドがこんなHTMLを吐いてくれる、って寸法です。

<html>
<head><meta charset="utf-8"></head>
<body bgcolor="white"><h1>Amazon について 教えて</h1></br><table><tr><td>こんな説明が見つかりました。Amazon.co.jpは、Amazon.comの日本法人アマゾンジャパン合同会社が運営する日本のECサイトである。<br></td></tr><tr><td><img src="https://m.media-amazon.com/images/S/com.evi.images-irs/premium/f2/f28d2f62577ae7274f583eb8e5dbc1a2._UL600_.png"/></td></tr></table><div align="right"><img src="https://images-na.ssl-images-amazon.com/images/G/01/mobile-apps/dex/avs/docs/ux/branding/mark3._TTH_.png" /></div></body>
</html>

これで、Alexaから出力される応答をHTMLにして、応答時に任意のフォルダ、ファイル名で書きだせるようになりました。

Google Assistant

Google Assistant側ではこんな七面倒なことをする必要はなく 2、Googleからの応答時にサーバ側からHTML5形式で画面情報を直接戻させることができます。

この中の、ScreenModeの設定を行うことで、Google AssistantからのHTML5応答をbytes配列に受け取れるようになります。

私はPushToTalkを改造してこんな感じに設定しました。

config.screen_out_config.screen_mode = embedded_assistant_pb2.ScreenOutConfig.PLAYING

この設定が有効になっていると、Google Assistantの応答(resp)内に、screen_out.dataというbytesが含まれるようになります。

デフォルトではPushToTalkはこの内容をブラウザ表示しますが、代わりにHTMLファイルとして吐き出してやります。

            if resp.screen_out.data:
                # print(resp.screen_out.data.decode())
                # system_browser = browser_helpers.system_browser
                # system_browser.display(resp.screen_out.data)
                html_file = open("/var/www/html/assistant/index.html","w")
                html_file.write(resp.screen_out.data.decode('utf-8'))
                html_file.close()

 ここまでやると、

  • Alexa/Google Assistantの両アシスタントは、応答完了後に応答内容を指定されたHTMLファイルに出力する

 ができるようになるので、あとはこの内容をMagicMirrorに描画させるだけです。

Assistantから得た画面出力を、どのようにMagicMirrorに表示させるか?

 方法はたくさんあると思うんですが、最初にうまくいったのは、

  • Raspberry Pi側にHTTPサーバを立てて、MagicMirrorのPluginにHTTPで取得させる
  • AssistantsスクリプトからHTTP APIで取得タイミングを通知する

 って方法でした。

HTTPサーバのインストール

とりあえずここではNginxにしましたが、別に何を使ってもよいです。

ここでのポイントは、Assistantからの最後の応答を特定の位置に書き出し、Raspberry Piの特定のURLから取得可能にすることだけです。

SmartDisplay_Browser.png

ここでは、"(RaspberryPiのIPアドレス)/assistant/"をHTML取得用URLにしておいて、このフォルダのindex.htmlを両Assistantから出力するように構成しておきます。

MagicMirrorからのHTML取得

あとは、MagicMirrorのHTML表示プラグインから、このURLにアクセスしてHTMLを取得し、表示するだけです。

HTML取得と表示はMagicMirrorのMMM-iFrame-Pingからできるのですが、このプラグインによるHTMLの取得は、一定時間ごとにしか実施できないので、

  • Assistantが応答したタイミングでHTMLを取得し、応答内容を反映する

という振る舞いを追加してやる必要があります。

そこで、MMM-RemoteControlというプラグインを入れ、MMM-iFrame-Pingの表示をRaspberry PiからHTTP APIで切り替えることで、この機能を実現してみました。

def showCard(self,assistantNo):
    getRequestURL = 'http://192.168.10.3:8080/remote?action=SHOW&module=module_6_MMM-iFrame-Ping'
    try:
        response = requests.get(getRequestURL)
    except requests.exceptions.RequestException:
        print("Magic Mirror is not connected.")
        return
    print(response.text)

このコードはRaspberry Pi上のAssistantsスクリプトに統合されていて、AssistantがRaspberry Pi上でHTMLを出力した後のタイミングで呼び出されます。

showCardは描画サーバ上のMagicMirrorにHTTPリクエストを送り、MMM-iFrame-Pingを再描画させます。この時にHTMLの再取得が行われるので、MagicMirrorはAssistantが直近に応答した内容のHTMLファイルを取得して表示できる、というわけです。

以上の更新はすべてiPad上の画面にxrdp経由でリモート表示され、iPadがスマートディスプレイライクな動作をしているように見える、というカラクリでした。

おわりに

正月休みの終盤に思い付いたネタで、仕事も始まるのに試行錯誤できるかなぁと思ってましたが、やってみるとなかなか面白かったです。目に見えて動きのある新しいデバイスを自分で作るのって、なかなか楽しいですよね。

自分にとって新しかった知見は、

  • 応答速度の遅さをある程度許容できるなら、スマートディスプレイはリモート描画で実装できる。
    • マイク、スピーカー、画面だけがあればスマートディスプレイを作ることができる。

ってことでしたね。別にリッチなエッジがなくても、サイネージ、センサ、アクチュエータの3つがあれば、スマートディスプレイって作れるんだなぁ、というのは新たな気づきでした。

とりあえず現時点ではここまでですが、もしかするともう一つ実験をするかもしれません。うまくいったらこの記事か、別記事を立てて共有しますので、期待せずにお待ちください。

それでは、皆様もRaspberry Piと、スマートディスプレイを引き続きお楽しみください。

  1. 描画用サーバ+BlueToothマイク/スピーカだけにするとか、Raspberry Pi3をB+にするとか。

  2. この辺はAlexaとGoogle Assistantの設計思想の違いが見えて面白かったですね。カスタマイズ性と通信量を選んだAlexaと、描画内容の統一とエッジ側描画処理の省力化を選んだGoogle Assistant、それぞれに考えるところがあったでしょうから。

14
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?