Java
RaspberryPi
SpringBoot
GoogleHome

Google Homeをリモートでしゃべらせる(Websocket使用)

Google Home、面白いですね。

@miso_developさんのGoogle Homeでやったことまとめに心動かされて、いくつか写経させていただきました。

IFTTTを使用するとLINE送信もできる、とのことで導入したところ、小学生の娘が喜んでLINE送信してくれるようになり、そうなるとGoogle Homeに返答をしゃべらせたくなってきます。
調べてみると、Google Homeに発話させるのは
またまた@miso_developさんのWebからGoogle Homeを喋らせたり家電操作したりしてみるがあり、以下の手順でできるようです。

1.Node.jsのライブラリ「google-home-notifier」を使用するとGoogle Homeに発話させることができる。
2.1は自宅LAN内で実行する必要があり、リモートでのトリガーにRealtimeDBのFirebaseを使用する。

自分は自宅向けにSpring Bootでシステム構築してあったので、2のトリガーとなる部分をwebsocketにしてみました。Spring使えるとセキュリティ実装に悩まないことが大きいです。
Spring Securityは圧倒的に便利。

参考:@opengl-8080さんのSpring Security 使い方メモ 認証・認可

機材

Raspberry PI3
ウサギを飼っているので室内の気温ロガー&エアコン制御で導入済みでした。

Google Home mini
コストコで¥5,000くらいだったような。

実装

コードはこちら
https://github.com/ko-aoki/web2Googlehome

Spring Boot 1.5.10で実装しています。

websocket周りの説明は以下を参考にしました。

サーバサイド

WebSocketの設定クラスです。
websocketのエンドポイントを設定、メッセージブローカーを有効にして宛先を設定します。

WebSocketConfig.java
package com.example.web2googlehome;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/endpoint");
    }

}

通常のWebアプリケーションのコントローラクラスです。
パラメータ"send"つきでPOSTされたら
SenderDtoをWebSocketConfig.javaで設定した宛先に送信します。

SenderController.java
package com.example.web2googlehome.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/top")
public class SenderController {

    SimpMessagingTemplate simpMessagingTemplate;

    @Autowired
    public SenderController(SimpMessagingTemplate simpMessagingTemplate) {
        this.simpMessagingTemplate = simpMessagingTemplate;
    }

    @GetMapping
    public String top(Model model) throws Exception {
        model.addAttribute("senderDto", new SenderDto());
        return "top";
    }

    @PostMapping(params = "send")
    public String send(SenderDto dto, Model model) throws Exception {
        model.addAttribute("senderDto", dto);
        this.simpMessagingTemplate.convertAndSend("/topic/notification", dto);
        return "top";
    }
}


SenderDtoはこんな感じ。

SenderDto.java
package com.example.web2googlehome.controller;

public class SenderDto {

    private String message;

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

発話のトリガーとなるPOSTを実行するHTMLはthymeleafで。
(もちろん、普通にJavaScriptでメッセージをsendしても。)
「送信」押下で、メッセージをSenderController.javaのsendメソッドでハンドリングしてくれます。

top.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8"/>
</head>
<body>
<div>
    <form action="/top"
          th:object="${senderDto}"
          th:action="@{/top}" method="post">
        <div>
            <label for="message">メッセージ</label>
            <input id="message" type="text" th:field="*{message}"/>
        </div>
        <div class="form-group">
            <button type="submit" class="btn-primary" name="send">送信</button>
        </div>
    </form>
</div>
</body>
</html>

クライアントサイド(Raspberry PI)

"ws://localhost:8080/topic/notification"をsubscribeして、
メッセージ通知されたらgoogle-home-notifierをキックして発話します。
stompFailureCallbackは切断時の再接続ロジックです。
環境によってはheartbeatしないとすぐ切断しちゃうかも。

web2googlehome-client.js
const Stomp = require('stompjs');
const Googlehome = require('google-home-notifier');
const language = 'ja';
var ip = 'xx.xx.xx.xx'; //ここにGoogle HomeのIPを記載

Googlehome.device('', language);
Googlehome.ip(ip, language);

var client;  // stompクライアント

var stompFailureCallback = function (error) {
    console.log('STOMP: ' + error);
    client = Stomp.overWS('ws://localhost:8080/endpoint');
    setTimeout(stompConnect, 5 * 60 * 1000);
    console.log('STOMP: Reconecting in 5 minutes');
};

var stompSuccessCallBack = function (frame) {
    console.log('connected to Stomp');
    client.subscribe('/topic/notification', function (data) {

        var obj = JSON.parse(data.body);
        console.log("received message " + obj.message);

        Googlehome.notify(obj.message , function(res) {
            console.log(res);
        });

    });
}

var stompConnect = function() {
    client = Stomp.overWS('ws://localhost:8080/endpoint');
    console.log('connecting to Stomp');
    client.connect('', '', stompSuccessCallBack, stompFailureCallback);
}

stompConnect();

自分がデプロイしている環境だと、50secでコネクション切断してしまうので
heartbeatをこんな感じにしてます。
あとはサービス化して、OS起動時や不慮のプロセス停止に対応。

web2googlehome-client.js(heartbeatあり)
const Stomp = require('stompjs');
const Googlehome = require('google-home-notifier');
const language = 'ja';
var ip = 'xx.xx.xx.xx'; //ここにIPを記載

Googlehome.device('', language);
Googlehome.ip(ip, language);

var client;  // stompクライアント
var intervalId; // heartbeatのintervalID

var stompFailureCallback = function (error) {
    console.log('STOMP: ' + error);
    clearInterval(intervalId);
    setTimeout(stompConnect, 5 * 60 * 1000);
    console.log('STOMP: Reconecting in 5 minutes');
};

var stompSuccessCallBack = function (frame) {
    console.log('connected to Stomp');
    client.subscribe('/topic/notification', function (data) {

        var obj = JSON.parse(data.body);
        console.log("received message " + obj.message);

        Googlehome.notify(obj.message , function(res) {
            console.log(res);
        });

    });
    // heartbeat
    intervalId = setInterval(function () {
        client.send('/app','');
    }, 10 * 1000)
}

var stompConnect = function() {
    client = Stomp.overWS('ws://localhost:8080/endpoint');
    client.heartbeat.outgoing = 10000;
    console.log(new Date() + ' connecting to Stomp');
    client.connect('', '', stompSuccessCallBack, stompFailureCallback);
}

stompConnect();

まとめ

業務アプリっぽい構成で、Google Homeに発話させることができました。
意外と仕事で使うこともあるかもしれませんね。