全世界の夜更かしさんに送る、Google Home(mini) + Nature Remo + 鯖(Synology NAS) + Node.jsでつくる夜更かし防止装置のすヽめ(google-home-notifier未使用)

# はじめに
ついつい夜更かしをしてしまうの方に向けにGoogle HomeとNature Remoを組み合わせて**「指定した時間以降、部屋が明るければGoogle Homeより早く寝るように警告を発する装置」**をNode.jsで実装する作例をご紹介します！！
市販品を組み合わせるだけなのでお手頃に作れます！！(たぶん) 

ちなみに似たような作例はよくありますが、多くの記事では**「google-home-notifier」**と呼ばれるGoole Homeに簡単にプッシュ発話をさせるライブラリが使われており、google-ttsの仕様に依存していたり、バグが多かったりして動かないことが多いのでなるべく根本ロジックから実装する方法でやっていきます。

### 構成
![プレゼンテーション1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57833/38a65fd7-f35b-6cb2-aad3-db97d179cdbb.png)

# 必要なもの
### Nature Remo
所謂スマートリモコン。TVやエアコンなどの赤外線リモコン信号を記憶し、WiFiに繋がったこの製品から記録した赤外線信号を発することで、スマホからまとめて家電操作を可能にする製品。　帰宅前に会社からエアコンを操作して部屋を冷やしとくなどの使い方ができます。 
色々なメーカーから類似品が大量に販売されていますが、この**Nature Remo**には温度、湿度、照度、人感センサーなどが搭載されており、しかも**外部APIから値を取得**することができるのです！！　このAPIを応用して部屋の照度を取得します。

### Google Home (miniでも可) 
言わずと知れたGoogle製スマートスピーカー。 このGoogle Homeより**「早く寝ろ!!」**といった感じに音声で警告するようにします。しかし、現状の公式SDKでは自発的に発音させることができません。。。
そこで、Google製スマートデバイスで搭載されている`「キャスト機能」`を活用します。キャスト機能とは、**「音楽や動画をストリーミングデータとして他のデバイスに配信する」**機能のことで、スマホのYoutubeアプリからTV（クロムキャスト）に飛ばして再生したり、Spotifyアプリから音楽をGoogle Homeに飛ばして再生することができます。  このキャスト機能を応用することで、任意の音声ファイルをGoogle Homeに再生させることができます。

### ローカルサーバーか何か(RaspberryPiやNASなど)
基本的にGoogle Homeにキャストできるのはローカルデバイスに限られるので、ローカルネットワーク内で常時動かせるNode.js環境が必要になります。　ルータに穴を開ける、ngrokを使って中継させる方法もあるようです？？ 今回は、自宅に置いてあるSynology製NASのNode.js上でタスクスケジューラーを起点に動かします。  
(一から用意する人は、お手軽なWiFi搭載マイコンボードESP8266がコスパ最強かも？ 「ESP8266 から Google Home に喋らせるライブラリ」 https://qiita.com/horihiro/items/4ab0edf415916a2cd542) 

# 設計
### 照度の取得
「Nature Remo Cloud API」より取得します。　アクセストークンの発行が必要です。
https://developer.nature.global/

### 音声合成
警告音声の生成には無料で手軽に使える音声合成API`「VoiceText Web API」`を使います。　感情の操作もでき、本家にも劣らないほど流暢です。　使うにはAPIキーが必要なので、下記より利用申請しましょう。 
`「VoiceText Web API」`（レファレンス）
https://cloud.voicetext.jp/webapi

ゴリゴリにリクエストパラメータを書いて実装も良いですが、面倒なので有志の方が作られたNode.js向けライブラリもあるのでそちらを使います。
`「node-voicetext」`
https://github.com/pchw/node-voicetext

どんな感じの音声か知りたい人はこちらから⬇️
http://voicetext.jp/samplevoice/

### キャスト機能
キャスト機能の機器間の詳細なプロトコル情報はGoogle公式からは公開されていないよう？？ですが、有志の方が解析して再現したライブラリ?があるのでそちらを使いキャスト機能のクライアント部分を実装します。
`「castv2」`
https://github.com/thibauts/node-castv2

当初、VoiceText Web APIで生成したmp3音声ファイルをGoogle Homeにストリーミングで投げればいけると思っていましたが、どうやらGoogle Homeが受け取れるのはURL情報だけっぽい？ので、mp3ファイルを配信するWEBサーバを建てる実装も必要になります。

### Google HomeのIPアドレスの特定
キャストするには相手先のIPアドレス特定する必要があります。幸い、Google Homeは`「mDNS」`というプロトコルに対応しており、マルチキャストアドレス224.0.0.251、UDP5353ポートに向けてmDNS queryと呼ばれるメッセージを投げると対応している機器が一斉にIPアドレスを返します。また、この応答情報内のTXTレコードにGoogle Homeのデバイス名情報があるので、それで識別することができます。こちらも一から実装するのは面倒なのでライブラリに頼ります。
`「mdns」`
https://github.com/agnat/node_mdns


```
 interfaceIndex: 6,
  type: 
   ServiceType {
     name: 'googlecast',
     protocol: 'tcp',
     subtypes: [],
     fullyQualified: true },
  replyDomain: 'local.',
  flags: 2,
  name: 'Google-Home-Mini-ccebf2ba7hogehogehoge5ff2e9',
  networkInterface: 'en0',
  fullname: 'Google-Home-Mini-ccebhogehogehoge._googlecast._tcp.local.',
  host: 'ccebf2ba-7c7d-ed85-6986-c0af325ff2e9.local.',
  port: 8009,
  rawTxtRecord: <Buffer 23 69 64 3d 63 63 65 62 66 32 62 61 37 63 99 64 65 64 38 35 36 39 38 36 63 30 61 66 33 32 99 66 66 32 65 39 23 63 64 3d 36 42 44 33 99 33 33 36 36 30 ... >,
  txtRecord: 
   { id: 'ccebf2ba7c7ded9999af325ff2e9',
     cd: '6BD343366053C3C999990F6AF76C',
     rm: 'E705D56999969244F',
     ve: '05',
     md: 'Google Home Mini',
     ic: '/setup/icon.png',
     fn: 'ダイニング ルーム',
     ca: '198660',
     st: '0',
     bs: 'FA8FCA855A41',
     nf: '1',
     rs: '' },
  addresses: [ '192.168.1.21' ] 
```

# 実装
### 照度の取得
レファレンスに書いてある通りヘッダーにアクセストークンを入れてリクエストを投げます。

```js
async function getIl(){
   return axios.get('https://api.nature.global/1/devices', {
      headers: {
         'Accept': 'application/json',
         'Authorization': 'Bearer AjmyJhogehogKSRFg3owta-DfnahogehodksQ2g.ezTpIVtf-V8znl7HSfg-PYhogehoge0r0WxXTFA'
      },
      data: {}
   }).then(response => {
      return response.data[0].newest_events.il.val;
   }).catch(err => {
      console.log(err);
      reject(err);
   });
}
```

### 音声ファイルの生成
話者、感情値などの各種パラメータを設定しVoiceText Web APIより音声ファイルを取得してローカルに保存します。

```js
function convertToText(text) {
   return new Promise(function (resolve, reject) {
      voice
         .speaker(voice.SPEAKER.TAKERU)
         .emotion(voice.EMOTION.HAPPINESS)
         .emotion_level(voice.EMOTION_LEVEL.HIGH)
         .volume(150)
         .format('mp3')
         .speak(text, function (e, buf) {
               if (e) {
                  console.error(e);
                  reject(e);
               } else {
                  fs.writeFileSync(OUT_PATH, buf, 'binary');
                  resolve();
               }
         });
   });
}
```
### キャスト用音声ファイル配信WEBサーバを建てる
ExpressでWEBサーバーを建て、キャスト用のmp3音声ファイルを配信します。

```js
function startSever(){
   const server = app.listen(PORT, console.log('port: ' + PORT));
   app.get(`/audio/${FILE_NAME}`, (req, res) => {
      fs.readFile(`./${FILE_NAME}`, (err, data) => {
         if (err)
            res.status(400).send(err.toString());
         else {
            res.setHeader('Content-Length', data.length);
            res.write(data);
            res.end();
         }
      });
   });
   return server;
}
```

### Google HomeのIPアドレスを取得
mdnsクエリーを投げてレスポンスを数秒待ちます。　検索結果の中より対象デバイス名のIPアドレスを抜き出します。

```js
function searchDeviceIp(device_name){
   let device_ip = '';
   return new Promise((resolve, reject) => {
      browser.start();
      // serviceUpイベントより情報を抜き出す
      browser.on('serviceUp', (service) => {
         if(device_name.replace(/\s+/g, '') === service.txtRecord.fn.replace(/\s+/g, '')){
            device_ip = service.addresses[0];
         }
      });
      // 検索時間
      setTimeout(()=>{
         browser.stop();
         resolve(device_ip);
      }, SEARCH_TIMEOUT);
   });
}
```

### キャスト機能
キャストする際にGoogle Homeの音量をコントロールすることができます。エラーで死んだ時に必ずコネクションをクローズ！！

```js
function say(host, content){
   const server = startSever();
   const client = new Client();
   client.connect(host, ()  => {
      client.setVolume({ level: 0.5 }, (err, newvol) => {
         if(err)console.log("there was an error setting the volume");
      });
      client.launch(DefaultMediaReceiver, (err, player) => {
         if (err) {
            console.log(err);
            server.close();
            return;
         }

         player.on('status', status => {
            console.log(`status broadcast playerState=${status.playerState}`);
         });

         const media = {
            contentId: content,
            contentType: 'audio/mp3',
            streamType: 'BUFFERED'
         };
         player.load(media, { autoplay: true }, (err, status) => {
            client.close();
            server.close();
         });
      });
   });
   client.on('error', err => {
      console.log(`Error: ${err.message}`);
      client.close();
      server.close();
   });
};
```

#　最終実装
``` js
/*****************************************************************/
/*                 コンフィグ&パラメーター                       */
/*****************************************************************/

const NAS_IP = '192.168.22.9' /* NASのIPアドレス */
const PORT = 8083; /* NASのポート */
const FILE_NAME = 'voice.mp3'; /* 音声合成出力ファイル名 */
const GOOGLE_HOME_NAME = 'ダイニング ルーム';
const VOICE_MESSAGE = '寝る時間です！ 直ちに歯磨きをして就寝しましょう！！'
const NATURE_REMO_API_KEY = 'AjmyJV5syfoghogehogeREJdksQ2g.ezTpIVtf-V8znl7HJjrF0r0WxXTFA'; /* Nature Remo Cloud API アクセストークン */
const VOICE_TEXT_API_KEY = 'w7ahogehogebiam'; /* VoiceText Web API アクセストークン */

/* パラメーター */
const SEARCH_TIMEOUT = 2000; /* Goole Homeの探索時間(ms) */
const IL_THRESHOLD = 100; /* 照度の閾値 */
const ATTENTION_INTERVAL = 90 * 1000; /* 警告のインターバル(ms) */
const MONITORING_ENDTIME = dayjs().hour(3).minute(15).add(1, 'day'); /* 監視終了時間 次の日の３時１５分(日本時間)*/
const GOOGLE_HOME_VOLUME = 0.5; /* Google Homeのボリューム*/

/*****************************************************************/
/*                         ライブラリ                            */
/*****************************************************************/

/* 各種ライブラリ */
const fs   = require('fs');
const axios = require('axios');
const app  = require('express')();
const Client = require('castv2-client').Client;
const DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver;
const VoiceText = require('voicetext');
const voice = new VoiceText(VOICE_TEXT_API_KEY);
const mdns = require('mdns');
const browser = mdns.createBrowser(mdns.tcp('googlecast'));

/* 日付ライブラリ&日本ロケール設定 */
require('dayjs/locale/ja');
const dayjs = require('dayjs');
dayjs.locale('ja');

/*****************************************************************/
/*                 メインルーチン                                */
/*****************************************************************/

const OUTPUT_PATH = `./${FILE_NAME}`; /* 音声ファイル出力先 */
const URL = `http://${NAS_IP}:${PORT}/audio/${FILE_NAME}`; /* NAS上の音声ファイルURL */
const google_home_ip = searchDeviceIp(GOOGLE_HOME_NAME);

(async () => {
   // 音声データ作成
   await convertToText(VOICE_MESSAGE, OUTPUT_PATH);
   // 初回チェック
   if(!await isSleep()){
      say(google_home_ip, URL, GOOGLE_HOME_VOLUME);
   }
   // 監視開始
   const id = setInterval(async () => {
      if(!await isSleep()){
         say(GOOGLE_HOME_IP, URL, GOOGLE_HOME_VOLUME);
      }else if(dayjs().isAfter(MONITORING_ENDTIME)){
         // 監視終了
         clearInterval(id);
      }
   }, ATTENTION_INTERVAL);
})();

/*****************************************************************/
/*               各種関数                                        */
/*****************************************************************/

function searchDeviceIp(device_name){
   let device_ip = '';
   return new Promise((resolve, reject) => {
      browser.start();
      // serviceUpイベントより情報を抜き出す
      browser.on('serviceUp', (service) => {
         if(device_name.replace(/\s+/g, '') === service.txtRecord.fn.replace(/\s+/g, '')){
            device_ip = service.addresses[0];
         }
      });
      // 検索時間
      setTimeout(()=>{
         browser.stop();
         resolve(device_ip);
      }, SEARCH_TIMEOUT);
   });
}

function startSever(port, file_name){
   const server = app.listen(port, console.log('port: ' + port));
   // ルーティング設定
   app.get(`/audio/${file_name}`, (req, res) => {
      fs.readFile(`./${file_name}`, (err, data) => {
         if (err)
            res.status(400).send(err.toString());
         else {
            res.setHeader('Content-Length', data.length);
            res.write(data);
            res.end();
         }
      });
   });
   return server;
}

function say(host, content, volume){
   const server = startSever(PORT, FILE_NAME);
   const client = new Client();
   // デバイスへの接続
   client.connect(host, () => {
      // 音量設定
      client.setVolume({ level: volume }, (err, newvol) => {
         if(err)console.log("there was an error setting the volume");
      });
      // キャストの開始
      client.launch(DefaultMediaReceiver, (err, player) => {
         if (err) {
            console.log(err);
            server.close();
            return;
         }
         player.on('status', status => {
            console.log(`status broadcast playerState=${status.playerState}`);
         });
         const media = {
            contentId: content,
            contentType: 'audio/mp3',
            streamType: 'BUFFERED'
         };
         // 音声再生
         player.load(media, { autoplay: true }, (err, status) => {
            client.close();
            server.close();
         });
      });
   });
   // 接続できない場合のエラー処理
   client.on('error', err => {
      console.log(`Error: ${err.message}`);
      client.close();
      server.close();
   });
};

function convertToText(text, output_path) {
   return new Promise((resolve, reject) => {
      voice
         .speaker(voice.SPEAKER.TAKERU)
         .emotion(voice.EMOTION.HAPPINESS)
         .emotion_level(voice.EMOTION_LEVEL.HIGH)
         .volume(150)
         .format('mp3')
         .speak(text, (e, buf) => {
            if (e) {
               console.error(e);
               reject(e);
            } else {
               fs.writeFileSync(output_path, buf, 'binary');
               resolve();
            }
         });
   });
}

async function getIl(api_key){
   return axios.get('https://api.nature.global/1/devices', {
      headers: {
         'Accept': 'application/json',
         'Authorization': `Bearer ${api_key}`
      },
      data: {}
   }).then(res => {
      return res.data[0].newest_events.il.val;
   }).catch(err => {
      console.log(err);
      reject(err);
   });
}

async function isSleep(){
   const il = await getIl(NATURE_REMO_API_KEY);
   return (il < IL_THRESHOLD);
}

```

# 運用
### Google Homeのデバイス名の確認
自分で設定したデバイス名をお忘れの方は、スマホのGoogle Homeアプリより確認することができます。
<img width="400" alt="Screenshot_20191227-100900.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57833/ee8f928e-1d58-4f81-2bc8-75be5f59e440.png">

### NASへの設置、タスクスケジューラーの設定
NASの適当なディレクトリに実装したソースファイルを設置し、消灯時間に自動実行するようにタスクスケジューラーに登録します。
SynologyNASであれば、コントロールパネル→タスクスケジューラーより登録できます！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57833/6ac3db12-9b32-2259-b21b-fbf96ad4b0a0.png)


# これで夜更かし生活ともおさらば！！