Amazon echoとGoogle Spreadsheetで育児記録をつける

背景

先日第一子が産まれ、妻がスマホアプリでうんちやらおっぱいやらの記録をつけていたのだが、下記のような問題があった。

  • 妻以外の人(僕とか)は記録が見れない
  • 妻以外の人(僕とか)がおむつを変えたりミルクを上げたりしたときに記録がつけられない
  • スマホが手元にないと記録がつけられない(妻が授乳しながらスマホ取ってと言ってくること多数)

そこで、Amazon echo dotで記録をつけ、記録自体はGoogle Spreadsheetに集約することで上記の問題を解決してみた。
Google Spreadsheetはデータストアとフロントを兼ねてくれるのでかなり楽ちん(なはず)です。
ちなみに、かなりDIY感があって移植性皆無なやり方なのでアプリにして一般公開とか出来なさそう。。

要件

妻にヒアリングしたところ、下記のようなことが必要らしかった。

  • うんち、おしっこ、おっぱい、ミルクの時刻を記録して後から参照できる
  • ミルクに関してはあげた量も記録できる
  • 前回のうんち、おしっこ、おっぱい、ミルクから何時間経過したか簡単に参照できる
  • 一日あたりのうんち、おしっこ、おっぱい、ミルクの回数およびミルクの量を簡単に参照できる

ちなみに、一般的なアプリだとついている下記の機能は我が家には不要らしかったので作っていない。

  • 左右のおっぱいの区別およびそれぞれの授乳時間の記録
  • 寝た・起きた の記録

実装

全体の構成

全体の構成はこんな感じ。これは配置図というらしい。
図の中にはGoogle Formsがあるが、この記事では対象としない。 f:id:miyataro32:20180502201647p:plain

処理の流れはこんな感じ f:id:miyataro32:20180503224401p:plain

  • Alexaのカスタムスキルからは標準に従ってAWS Lambdaの関数を使う。
  • Google Spreadsheetの操作はGoogle Apps Scriptを使う。GASをかまさずにLambdaから直接spreadsheetのAPIを叩くことも出来たが、Spreadsheetの操作の実装はGoogle側に寄せてAmazon側はそれを叩くだけという構成の方がAlexa以外の他のインターフェイスを作りたくなったときなどに便利なため。

実装

Alexaカスタムスキルの作成

基本的な作成方法、Lambdaとの連携方法は下記の公式チュートリアルを見ればだいたい分かる。 developer.amazon.com

ざっくりと概念を説明すると、

  • スキルというのがAlexaにおけるアプリに相当する概念で、スキルの中にインテント(関数に相当)、インテントの中にスロット(関数の引数に相当)という概念がある。
  • Amazon Echoへの指示は、「アレクサ、スキルの呼び出し名インテントの発話」、例えば「アレクサ、育児ノートうんちを記録して」というように行う。
  • インテントの識別はサンプル発話をたくさん書くとそれを元にうまいことやってくれる。(うんち、うんこ、うんこ記録、うんちを記録して みたいな感じ笑)
  • 話した内容からスロットの値を識別するときにはスロットタイプ(引数型のようなイメージ)を数値にしておけば数値として聞き取るというような動きになる。

developer.amazon.com

この部分は本当にチュートリアルの通りにやれば出来てしまうので割愛。定義をGitHubにあげてあるのでそれを貼っておく。 github.com

AWS Lambdaの関数を作成してとりあえずカスタムスキルを動かす

まずは、チュートリアルにある通りにalexa-skill-kit-sdk-factskillをベースにbabyNoteのような名前で関数を作成し、作ったスキルと関数を紐付ける。 developer.amazon.com

いったん、下記のような感じでindex.jsを書いたらAmazon Echoに対するコマンドによってLambdaの関数が動いて関数の中で作られた応答がEchoから返ってくることが確認できるようになる。

"use strict";
const Alexa = require('alexa-sdk'); // Alexa SDKの読み込み
const gasAccessor = require('./gas-accessor');

const handlers = {
    // インテントに紐付かないリクエスト
    'LaunchRequest': function () {
        console.log('Processing LaunchRequest');
        this.emit('AMAZON.HelpIntent');
    },
    // スキルの使い方を尋ねるインテント
    'AMAZON.HelpIntent': function () {
        console.log('Processing HelpIntent');
        this.emit(':ask', 'うんち、おしっこ、おっぱい、ミルクが記録できます。何をしますか?');
    },
    'RegisterUnchiIntent': function () {
        console.log('Processing RegisterUnchiIntent');
        this.emit(':tell', 'うんちを記録しました。');
    }
};

// Lambda関数のメイン処理
exports.handler = function (event, context, callback) {    
    var alexa = Alexa.handler(event, context); // Alexa SDKのインスタンス生成
    alexa.appId = process.env.APP_ID;
    alexa.registerHandlers(handlers); // ハンドラの登録
    alexa.execute();                  // インスタンスの実行
};

const handlersの中に個々のインテントに対するハンドラーを記述する。
this.emitでAlexaに喋らせることができる。第一引数が:tellの場合は単に返答するだけ、:askの場合はもう一度ユーザの回答を受付け、その回答を元に再度インテントを解釈してハンドラーが動くというような流れになる。
詳しくは第三回のチュートリアルを参照。

Alexaスキル開発トレーニングシリーズ 第3回 音声ユーザーインターフェースの設計 : Alexa Blogs

Alexaコンソールの「テスト」タブを開き、テストを有効にすることでAlexaシミュレータおよび本物のEchoデバイスでスキルを試すことが出来る。
「アレクサ、育児ノートでうんちを記録して」のように話しかけると「うんちを記録しました」と返ってくるはず。
ここまででAlexaに特有な部分はクリアできたので、中身の実装に入っていく。

Google Apps Scriptの作成

Spreadsheetの作成

まずはGoogle Spreadsheetを作成する。

こんな感じでデータを記録するシートを作成し、シート名をrecordsとした。このシートが永続化層。
図はデータが挿入されたあとのもの。
f:id:miyataro32:20180503114814p:plain

また、プレゼンテーション層もSpreadsheetで作成する。スマホアプリのSpreadsheetで見やすいように縦長に作り、シート名はdashboardとした。すごく手軽!

f:id:miyataro32:20180503114824p:plainf:id:miyataro32:20180503114817p:plain
  • 左のスクリーンショット部分のデータはrecordsの情報をもとにGoogle Apps Scriptで動的に作っている。
    イベントの登録っていうのはAlexaではなくGoogle Formsで情報登録できる画面へのリンクで、これについては別の記事で書きます。
  • 右のスクリーンショットの部分はSpreadsheetの標準機能の関数とグラフで作っている。
    回数のカウントは=COUNTIFS(records!$A:$A, YEAR($B63)&"/"&MONTH($B63)&"/"&DAY($B63), records!$C:$C, "="&C$62)みたいな感じ。

Google Apps Scriptの作成

作成したSpreadsheetの上部メニューバーからスクリプト エディタを選択することでGoogle Apps Scriptのプロジェクトが作成される。
f:id:miyataro32:20180503120711p:plain

Google Apps Scriptはプロジェクトというのが実行環境の一単位となっており、プロジェクトを特定のコンテナ(今回で言うとさっき作成したSpreadsheet)に紐付けるとExcel VBAのような使い方ができるようになるらしい。
Spreadsheetからスクリプト エディタを選択するだけでプロジェクトの作成およびSpreadsheetとの紐付けを自動でやってくれる。

プロジェクト名はbaby-noteなど、適当に設定しておく。

このスクリプト エディタを使ってソースコードを書いていく。とりあえず基本的なことを書いておく。

  • スクリプトファイルの拡張子はgs。ほとんど(node.jsとかではなく)素のJavascriptで、SpreadsheetAppのようなコンテナを操作するためのクラスが用意されているものというざっくりした認識
  • 複数のgsファイルを作ることが出来るが、特にrequire的なことをしなくても全部のファイルが読み込まれ、その中で定義した関数や変数はすべてグローバルスコープとなる模様
  • 名前空間を切って関数や変数を整理することはできる(というかほぼ必須である)が、records.getRecords()のようなグローバルスコープでない関数は外部からAPI経由で叩いたり何らかのトリガーで実行したりすることはできず、そういうことがしたい場合はグローバルスコープでfunction getRecords()のように宣言する必要がある。

recordsに対するデータ追加

recordsシートに対するCRUDはrecords.gsというファイルを作ってまとめた。また、内部でしか使用しないものはrecordsという名前空間を切ってまとめた。そのうちの一部がこれ。

var records = {};

records.getSheet = function () {
  if (!records.sheet) {
    records.sheet = SpreadsheetApp.getActive().getSheetByName('records');
  }
  return records.sheet;
}

records.appendJournalRecord = function (type, opt_parameter) {
  var startTime = Date.now();
  
  var date = new Date();
  var row = [];
  row.push("'" + date.toLocaleDateString());
  row.push("'" + date.toLocaleTimeString().replace(/[^:0-9]/g, ''));
  row.push(TYPE_NAME[type]);
  if (opt_parameter) {
      row.push(opt_parameter);
  }
  
  records.getSheet().appendRow(row);
  
  var executionTime = Date.now() - startTime;
  Logger.log('appendJournalRecordWithSpecificDate took ' + executionTime + ' ms');
};

Spreadsheetに対する単純なデータの追加、読み出しはSpreadsheetApp.getActive().getSheetByName('records')のようにして取得したSheetオブジェクトに対してappendRow、getValueすることで行う。
基本的なことはここが分かりやすかった。
qiita.com

そして、外部から使用できる関数を下記のように作成する。

function registerUnchi() {
  var startTime = Date.now();

  records.appendJournalRecord(TYPE.UNCHI); // 新しいレコードを追加
  
  var values = {unchiCount: records.countRecords(TYPE.UNCHI, new Date())}; // 今日のうんちの回数をカウントしてオブジェクトに詰める
  Logger.log('registerUnchi : ' + JSON.stringify(values));
  
  var executionTime = Date.now() - startTime;
  values.executionTime = executionTime;
  Logger.log('registerUnchi took ' + executionTime + ' ms');
  return values; // 今日のうんちの回数が入ったオブジェクトを返す
}

普通にオブジェクトを作ってreturnすることでAPIのレスポンスにデータを入れることができる。
今回はうんちを登録し、本日何回目のうんちかという情報を返すようにした。

これで作った関数をテストできる。
画面上部で関数名を選択して実行ボタンをクリックするとrecordsシートに行が追加される。初回のみSpreadsheetへのアクセスの許可が必要になる。
f:id:miyataro32:20180503134925p:plain
実行後に表示 > ログをクリックするとLoggerで出力したログを見ることができる。今のところこれが最も有力なデバッグ方法。

dashboard用の処理の話も書こうかと思ったけど長くなるので割愛。
作ったGoogle Apps Scriptの全量はこちら github.com

AWS LambdaからGoogle Apps Scriptを叩くための設定と実装

Google Apps Scriptの関数を外部から叩ける用にする設定

これが結構ややこしかった。
とりあえずここに書いてある通りにすればだいたい行けるはず。 qiita.com

OAuth 2.0 Playgroundでトークン取得後にAPIを試してみる時のrequest bodyは下記のようにする。

{
  "function": "registerUnchi",
  "parameters": [],
  "devMode": false
}

devModeはtrueにしたほうが便利なのは間違いないのだが、下記で報告されている問題にぶち当たってしまい、かなりハマった。
原因は不明だが、devModeをtrueにしていてAPIを叩くと404が返ってくる場合にはfalseにするのが手っ取り早い。 stackoverflow.com

Lambda側でGASのAPIを叩く処理を実装する

ここから先はLambdaのインラインコード編集ではなく、アクション > 関数のエクスポートで落としてきたソースコードをローカルで編集する。
npmを使いたいのと、ライブラリを組み込んだ結果サイズが増えすぎてインラインコード編集ができなくなってしまったため。

まずはターミナルを開き、ダウンロードしたソースコードのルートディレクトリでnpm install googleapis@25.* --saveを実行してgoogleapiを叩くためのライブラリを入れる。

そして、下記のようにGASのライブラリを叩くモジュールgas-accessorを実装する。

const google = require('googleapis');
const OAuth2 = google.auth.OAuth2;

const CLIENT_ID = process.env['CLIENT_ID'];
const CLIENT_SECRET = process.env['CLIENT_SECRET'];
const ACCESS_TOKEN = process.env['ACCESS_TOKEN'];
const REFRESH_TOKEN = process.env['REFRESH_TOKEN'];
const SCRIPT_ID = process.env['SCRIPT_ID'];
const DEV_MODE = process.env['DEV_MODE'] ? /^true$/i.test(process.env['DEV_MODE']) : false;

const gasAccessor = {};

gasAccessor.executeFunction = function (functionName, callback, opt_parameter) {
    var startTime = Date.now();
    
    console.log('executeFunction started [functionName=' + functionName + ', parameter=' + opt_parameter);
    const auth = new OAuth2(CLIENT_ID, CLIENT_SECRET);
    auth.setCredentials({
        access_token: ACCESS_TOKEN,
        refresh_token: REFRESH_TOKEN
    });
    const script = google.script('v1');
    script.scripts.run({
        auth: auth,
        scriptId: SCRIPT_ID,
        resource: {
            function: functionName,
            parameters: [opt_parameter],
            devMode: DEV_MODE
        }
    }, (err, result) => {
        var turnAroundTime = Date.now() - startTime;
        console.log(functionName + ' API execution took ' + turnAroundTime + ' ms');
        if (err) {
            console.error(err);
        } else {
            console.log(result.data.response.result);
            callback(result.data.response.result);
            var callbackExecutionTime = Date.now() - startTime - turnAroundTime;
            console.log('callback execution took ' + callbackExecutionTime + ' ms');
        }
    });
};

module.exports = gasAccessor;

認証系の情報はLambdaの環境変数として受け取るようにしている。
また、executeFunctionではGASの関数名とcallback関数を引数で受け取ってGASの関数の処理が帰ってきたら任意のcallback処理を実行できるようにしてある。
GASの関数の返り値はresult.data.response.resultに入っており、これをcallback関数に渡すようにしてある。

これをindex.jsでこのように使う。

const gasAccessor = require('./gas-accessor');

const handlers = {
    'RegisterUnchiIntent': function () {
        console.log('Processing RegisterUnchiIntent');
        gasAccessor.executeFunction('registerUnchi', function (result) {
            this.emit(':tell', '本日' + result.unchiCount + '回目のうんちです');
        }.bind(this));
    }

これでregisterUnchiAPI経由で実行して、registerUnchiから返ってきたうんち回数を含むメッセージをAlexaに渡すことができる。

index.jsが存在する階層をzipで固めてLambdaにアップロードすることでLambdaのソースコードを更新できる。
その際、zipには親のフォルダを含めないよう注意する。 stackoverflow.com 自分はいちいち気を使ってzip作るのめんどいのでシェルスクリプトを書いた。 github.com

Lambda上ではこのように環境変数を設定する。 f:id:miyataro32:20180503142823p:plain

これで、Alexaコンソールのテストで「育児ノートでうんちを記録」と入れると「本日○回目のうんちです」と返ってくるはず。
返ってこなかったときはGASのスクリプトエディタやLambdaで原因を調査する。

ちなみに、Lambdaではテストイベントというのを作ってテストをすることができる。
Alexaコンソールに表示されるこいつを f:id:miyataro32:20180503144350p:plain Lambdaのここから入力してテストボタンを押せばAlexaを介すことなくテストができて便利。 f:id:miyataro32:20180503144612p:plain

まとめ

とりあえず、これでAmazon echo dotとGoogle Spreadsheetで育児記録をつけられるようになった。
ただ、今は1ヶ月検診がまだなのでずっと家にいるから常にAmazon echo dotが使えるが、出先でおむつ交換したときにどうするかという課題がある。
何回かすでに言及したが、それについてはGoogle Formを使って解決してみたので後日まとめます。