ぶるーすくりーん

ぽんこつプログラマ日記

Google Home から音声で Google Fit にアクティビティを追加する2(nodejs・Google Fit 編)

この記事は、Google Home から音声で Google Fit にアクティビティを追加する1(Firebase・IFTTT 編) のつづきです。
node.js アプリを作成し、Firebase の読み取り、Google Fit へのデータ登録を行っていきます。

抜粋で説明しているため、ソース全体はGitHubを参照してください。

URL 設計

Google Fit API を利用してユーザデータを登録するためには、Google で OAuth2 認証をする必要があります。
このため、ユーザにはアプリ起動後、web 画面を開いて OAuth2 認証してもらうことにしました。

1回認証してもらった後は、トークン期限切れになっても、Google Fit API 実行時に裏でリフレッシュトークン認証をしてトークンを再発行することで、ユーザのアクションは不要としています。

パス 処理
/ アプリホームページ
Google ログイン済みでない場合はログインリンクを表示
/auth/google Google OAuth2 画面へリダイレクト
/auth/google/callback Google OAuth2 のコールバック URL
トークンをメモリに保存して / にリダイレクト

Firebase アプリの秘密鍵の生成

アプリに Firebase を追加する の手順に従い、Firebase に admin 権限でアクセスするための秘密鍵を生成します。

Firebase の読み込み

web 画面が必要となるため、 今回は express でアプリを作成しました。

expresss 初期化

$ mkdir google-assistant-hook
$ cd google-assistant-hook
$ express --git --hbs .
$ npm i

Firebase Admin SDK の追加

$ npm install firebase-admin --save

Firebase のデータ読み込み

app.js の末尾に以下のコードを追加します。

var admin = require('firebase-admin');
// Firebase アプリの秘密鍵の生成でダウンロードした JSON ファイル
var serviceAccount = require('../config/serviceAccountKey.json');

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: `https://refined-byte-XXXXXX.firebaseio.com`
});

var db = admin.database();

var ref = db.ref('google-fit/log');
ref.on('child_added', function (snapshot, prevChildKey) {
  console.log(prevChildKey, snapshot.val());

  // 読み終わったデータの削除
  var logRef = ref.child(prevChildKey);
  logRef.set(null);

}, function (err) {
  console.log('Failed to read data', err);
});

Firebase の google-fit/log にデータが追加されるとイベントフックが呼び出されてコンソールに値が表示されます。
ログデータは、読み終わったら不要となるため、データを Firebase 上に溜め込まないためにもその場で削除しています。

Google Fit のアクティビティデータの登録方法

Google Fit API を利用することで、Google Fit にアクティビティのデータを登録することができます。

データの登録は、以下の流れで行います。

  1. アプリでデータを登録する領域( Data Sources )を作成します(初回のみ)
  2. Data Sources に アクティビティデータ( Data Sets )を登録します

Google Fit API の詳細については、Google Fit API のドキュメント を参照してください。

curl サンプル

TOKEN は Google OAuth 2.0 Playground で作成することができます。
ただし、Google OAuth 2.0 Playground のトークンを利用して作成した Data Sources には、OAuth2 認証したアプリから Data Sets を登録できないので、curl で試す場合には、アプリとは別の名前で登録してください。

Data Source 登録

curl --header "Authorization: Bearer $TOKEN" -X POST \
  --header "Content-Type: application/json;encoding=utf-8" \
  "https://www.googleapis.com/fitness/v1/users/me/dataSources" \
  -d '{
  "device": {
    "uid": "100000000",
    "type": "phone",
    "version": "",
    "model": "SO-02H",
    "manufacturer": "Sony"
  },
  "dataType": {
    "name": "com.google.activity.segment",
    "field": [
      {
        "format": "integer",
        "name": "activity"
      }
    ]
  },
  "application": {
    "version": "1",
    "name": "Log Google Home",
    "detailsUrl": "http:\/\/example.com"
  },
  "type": "derived",
  "dataStreamName": "LogGoogleHome"
}' -i

Data Sets 追加

https://www.googleapis.com/fitness/v1/users/me/dataSources/dataStreamId/datasets/startTimeNanos-endTimeNanos

パラメータ
dataStreamId Data Source 登録のレスポンスに含まれる dataStreamId を URL エンコードした値
startTimeNanos アクティビティ開始時刻の UnixTime (ナノ秒)
endTimeNanos アクティビティ終了時刻の UnixTime (ナノ秒)
curl -XPATCH "https://www.googleapis.com/fitness/v1/users/me/dataSources/derived%3Acom.google.activity.segment%3A407408718192%3ASony%3ASO-02H%3A16062204%3ALogGoogleHome/datasets/1513112400000000000-1513116000000000000" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
  "dataSourceId": "derived:com.google.activity.segment:407408718192:Sony:SO-02H:16062204:LogGoogleHome",
  "maxEndTimeNs": "1513116000000000000",
  "minStartTimeNs": "1513112400000000000",
  "point": [
    {
      "dataTypeName": "com.google.activity.segment",
      "endTimeNanos": "1513116000000000000",
      "startTimeNanos": "1513112400000000000",
      "value": [
        {
          "intVal": 21,
          "mapVal": []
        }
      ]
    }
  ]
}' -i

Google Fit へのデータ登録

Google APIs Node.js Client

Google Fit の API 呼び出しには、Google APIs Node.js Clientを利用しました。
このライブラリはまだ、アルファ版なので遊び以外の目的で利用する場合はご注意ください。

npm install googleapis --save

OAuth2 用の設定

Google Fit にアクティビティデータを登録するには、OAuth2 のトークンが必要なので、まずは OAuth2 認証処理を実装します。
Google API Console認証情報 を開き、 OAuth 2.0 クライアント ID にアプリケーションを追加します。

今回は、Firebase が作成したところを編集して以下を定義しました。

f:id:tajima0111185:20171217190334p:plain

上記を定義後、ページ上部のリンクから JSON をダウンロードして express プロジェクトの config 配下に googleClientSecret.json という名前で配置しておきます。

OAuth2 認証

トークン管理用のクラス GoogleOAuthClient を作成します。

本アプリを利用するのは1人の前提なので、認証済みのクレデンシャル情報はシングルトンとしてサーバメモリ上で保持します。
Google APIs Node.js Client の OAuth2 Client を生成し、メンバ変数として保持しています。

var google = require('googleapis');

var clientKey = require('../config/googleClientSecret.json');

var OAuth2 = google.auth.OAuth2;

class GoogleOAuthClient {

  constructor() {
    this.client = new OAuth2(
      clientKey.web.client_id,
      clientKey.web.client_secret,
      clientKey.web.redirect_uris[1]
    );
  }

  getClient() {
    return this.client;
  }

  isAuthenticated() {
    return Boolean(this.client.credentials && this.client.credentials.expiry_date);
  }
}

module.exports = new GoogleOAuthClient();

/auth/google へアクセスがあった場合に OAuth2 画面を表示し、OAuth2 callback 時にクレデンシャルを保存します。

app.use('/auth', require('./routes/auth'));
var express = require('express');
var GoogleOAuthClient = require('../lib/GoogleOAuthClient');

var router = express.Router();

var oauth2Client = GoogleOAuthClient.getClient();
var scopes = [
  'https://www.googleapis.com/auth/fitness.activity.write',
  'https://www.googleapis.com/auth/fitness.blood_glucose.write',
  'https://www.googleapis.com/auth/fitness.blood_pressure.write',
  'https://www.googleapis.com/auth/fitness.body.write',
  'https://www.googleapis.com/auth/fitness.body_temperature.write',
  'https://www.googleapis.com/auth/fitness.location.write',
  'https://www.googleapis.com/auth/fitness.nutrition.write',
  'https://www.googleapis.com/auth/fitness.oxygen_saturation.write',
  'https://www.googleapis.com/auth/fitness.reproductive_health.write'
];
var oauthUrl = oauth2Client.generateAuthUrl({
  access_type: 'offline', // refresh_token を発行
  prompt: 'consent', // 毎回認証画面を出す(毎回 refresh_token を発行するため)
  scope: scopes
});

router.get('/google', function (req, res) {
  res.redirect(oauthUrl);
});

router.get('/google/callback', function (req, res, next) {
  // トークン取得
  // トークンが有効期限切れの場合は自動的にライブラリ内でリフレッシュトークンを取り直してくれる
  oauth2Client.getToken(req.query.code, function (err, tokens) {
    if (err) {
      console.error('Failed to get token.');
      return next(err);
    }
    oauth2Client.credentials = tokens;
    console.log(oauth2Client.credentials);

    res.redirect('/');
  });
});

module.exports = router;

npm start でアプリを起動し、ブラウザで http://localhost:3000/auth/google にアクセスし、コンソール表示を確認します。
アクセストークンが表示されれば成功です。

Google Fit Data Source の作成

上記で取得したアクセストークンを利用して、Data Source 登録curl を実行し、アプリ用の Data Source を作成します。

Google Fit への登録

GoogleFit アクセス用のクラスを作成します。

var google = require('googleapis');
var GoogleOAuthClient = require('../lib/GoogleOAuthClient');
var config = require('../config/app.json');

var oauth2Client = GoogleOAuthClient.getClient();
var fitness = google.fitness('v1');

class GoogleFit {
  regist(data) {
    switch (data.text) {
      case '開始':
        this.start = new Date().getTime() * 1000000;
        break;

      case '終了':
        if (!this.start) {
          // 開始済みでない場合は無視
          break;
        }
        this.end = new Date().getTime() * 1000000;

        if (!GoogleOAuthClient.isAuthenticated()) {
          console.error('Failed to get token. Please authorize application before at http://localhost:3000.');
          break;
        }
        this.createDataSets();
        break;

      default:
        // 開始・終了以外のデータの場合何もしない
        break;
    }
  }

  createDataSets() {
    return new Promise((resolve, reject) => {
      var params = {
        auth: oauth2Client,
        userId: 'me',
        dataSourceId: config.googleFit.dataSourceId,
        datasetId: `${this.start}-${this.end}`,
        resource: {
          'dataSourceId': config.googleFit.dataSourceId,
          'maxEndTimeNs': this.end,
          'minStartTimeNs': this.start,
          'point': [{
            'dataTypeName': config.googleFit.dataType,
            'endTimeNanos': this.end,
            'startTimeNanos': this.start,
            'value': [{
              'intVal': 21, // 健康体操
              'mapVal': []
            }]
          }]
        }
      };

      fitness.users.dataSources.datasets.patch(params, (err) => {
        // 開始・終了時間を初期化
        this.start = undefined;
        this.end = undefined;

        if (err) {
          console.error('Failed to regist dataset to Google Fit.', err);
          return reject(err);
        }
        return resolve();
      });
    });
  }
}

module.exports = GoogleFit;

Firebase 読み込み処理のところに Google Fit 登録の呼び出しを追加します。

var ref = db.ref(config.database.path.googleFitLog);
ref.on('child_added', function (snapshot, prevChildKey) {
  console.log(prevChildKey, snapshot.val());

  if (!prevChildKey) {
    // 何故か1つ目のデータのキーが null となってしまうため、ゴミデータとして無視する
    return;
  }
  // 非同期でデータ登録
  googleFit.regist(snapshot.val());

  // 読み終わったデータの削除
  var logRef = ref.child(prevChildKey);
  logRef.set(null);

}, function (err) {
  console.log('Failed to read data', err);
});

動作確認

以上で実装は終了です。

npm start でアプリを起動します。

Google Home に「OK グーグル!健康体操を開始して!」とつぶやいてみましょう。さらに10分後くらいに「OK グーグル!健康体操を終了して!」とつぶやいてください。
Google Fit アプリで健康体操の記録が追加されていれば成功です。
※あまり時間が短いとアプリで記録の表示が省略されてしまう場合があるので、10分程度開始から終了の間隔を置いてみてください。

感想

IFTTT に webhook 機能があることは知っていましたが、利用するには外部公開用のサーバをたてるしかないと考えていました。
なので、Firebase のリアルタイム DB を利用することで、無料で IFTTT とホームネットワーク内にたてたサーバとを連携することができて驚きました。

今は適当な Linux PC にアプリをインストールして動かしていますが、次は Raspberry pie を購入して赤外線モジュールをつなげて、エアコン操作・・・なんてことに挑戦してみたいなと思っています。