はじめに
ここのところスマートウォッチやサイクルコンピュータなど、クラウド上でヘルスデータ管理ができるデバイスを使っているのですが、データが分散してしまうことが気になっていました。
そこで、使用しているデバイスとデータ同期が可能な GoogleFit にデータを集約しようと考えたのですが、TANITA の体重計だけ目ぼしい同期アプリが見当たらないんですよね。
ヘルスデータを管理する上で体重が管理できないのは痛すぎる・・・、ということで、自称エンジニアの強みを活かして自作してみました(小声)。
尚、ソースも掲載しているので記事の通り設定すれば動くはずですが、不具合があっても対応はしませんので自己責任で利用してください。一応お約束ごとなので念の為。
全体概要
これから作る仕組みの全体概要です。シンプルすぎて特に説明もないのですが Google Apps Script(以下 GAS)に配置したプログラムで HealthPlanet からデータを引っこ抜いて、GoogleFit ブチ込んでやろうっていう算段です!
HealthPlanet API の準備
TANITA の体重計データは HealthPlanet というサービス上で管理されているのですが、ありがたいことに API が無償で提供されているので、これを使っていきます。
API を使うには OAuth 認証を行う必要があるので、まずは HealthPlanet 上で API の設定を行っていきます。今回は GAS に配置したプログラムから呼び出しを行うので、ホストドメインには script.google.com
を指定します。
登録が完了するとクライアント ID とクライアントシークレットが発行されます。特に難しいところはないですよね。
クライアント ID とクライアントシークレットは、この後の手順で使用するので控えておいてください。
GoogleFit API の準備
こちらの API も利用に際して OAuth 認証が必要なので設定をしていきます。まずは Google Cloud Platform にログインして、適当な名前でプロジェクトを作成します。
プロジェクトが作成できたら、メニューから API とサービス > OAuth 同意画面 を選択し、「User Type」に 外部
を選択して作成をクリックします。この後にいくつか画面が表示されますが必須項目だけ入力すれば問題ありません。
続いて、画面左から 認証情報 を選択し、上部のメニューから 認証情報を作成 > OAuth クライアント ID を選択します。
画面が切り替わったら「アプリケーションの種類」に ウェブアプリケーション
を指定します。また、続く「承認済みの JavaScript 生成元」には GAS のアドレスである https://script.google.com
を指定し、「承認済みのリダイレクト URI」には https://script.google.com/macros/d/{SCRIPT ID}/usercallback
を指定します。
{SCRIPT ID} には GAS の Script ID を指定する必要がありますが、この後の手順で作成するので現時点では適当な文字列を仮で指定しておいてください。
作成ボタンをクリックするとクライアント ID とクライアントシークレットが表示されます。
クライアント ID とクライアントシークレットは、この後の手順で使用するので控えておいてください。
ここまで来たら後もう一息です。左のメニューから ライブラリ を選択し、Fitness API
を検索して有効化しましょう。
最後に OAuth 同意画面 に戻ってアプリを公開したら完了です。お疲れさまでした!
データ転送プログラム
ようやくお膳立てが終わりましたね😭、ここからが本題です・・・。GoogleDrive を開いてスタンドアロン型として GAS プロジェクトを作成してください。GAS 自体の使い方については本筋からそれるので今回は割愛します。
リダイレクト URI の設定
早速コードを書いていきたいところなのですが、先程の 手順 で積み残していたリダイレクト URI の設定を先に行ってしまいたいと思います。
スクリプトエディタの左側に プロジェクトの設定 メニューがあるのでこれを選択します。表示された画面にプロジェクトのスクリプト IDが記載されているので、これをコピーして「リダイレクト URI」の {SCRIPT ID}
部分に埋め込んで保存してください。
ライブラリの追加
次に必要なライブラリを追加していきましょう。ライブラリ メニューの右側にあるプラスをクリックして追加画面を表示します。
画面が表示されたら、下記のスクリプト ID で検索を行ってライブラリを追加してください。
スクリプト ID | ID | 用途 |
---|---|---|
1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF | OAuth2 | OAuth2 認証の実装を簡単にしてくれるライブラリ |
1ShsRhHc8tgPy5wGOzUvgEhOedJUQD53m-gd8lG2MOgs-dXC_aCZn9lFB | dayjs | 日付操作を便利にしてくれるライブラリ |
HealthPlanet 用定義ファイルの作成
ここからコードに着手していきます。まずは HealthPlanet 用の定義ファイルを作っていきましょう。HealthPlanet API の準備 で作成した クライアント ID
と クライアントシークレット
を該当部分に転記して完成させてください。
const DATE_OF_MEASUREMENT = "1";
const BODY_WEIGHT = "6021";
const BODY_FAT = "6022";
const healthPlanet = {
"serviceName": "HealthPlanet",
"clientId": "(クライアント ID を転記)",
"clientSecret": "(クライアントシークレットを転記)",
"setAuthorizationBaseUrl": "https://www.healthplanet.jp/oauth/auth",
"tokenUrl": "https://www.healthplanet.jp/oauth/token",
"innerscanUrl": "https://www.healthplanet.jp/status/innerscan.json",
"callback": "hpAuthCallback",
"scope": "innerscan",
"grantType": "authorization_code",
"payloadDate": DATE_OF_MEASUREMENT,
"payloadTag": `${BODY_WEIGHT},${BODY_FAT}`
}
GoogleFit 用定義ファイルの作成
GoogleFit 用の定義ファイルも同じ要領でサクッと作っていきます。GoogleFit API の準備 で作成した クライアント ID
と クライアントシークレット
を該当部分に転記してください。
const googleFit = {
"serviceName": "GoogleFit",
"clientId": "(クライアント ID を転記)",
"clientSecret": "(クライアントシークレットを転記)",
"setAuthorizationBaseUrl": "https://accounts.google.com/o/oauth2/auth",
"tokenUrl": "https://oauth2.googleapis.com/token",
"dataSourceUrl": "https://www.googleapis.com/fitness/v1/users/me/dataSources",
"callback": "gfAuthCallback",
"scope": "https://www.googleapis.com/auth/fitness.body.write",
"weight": "com.google.weight",
"fat": "com.google.body.fat.percentage"
}
転送処理本体の作成
こちらは特に修正するところはないのでコピペで OK です。使い方やポイントはこの後述べるので、ササッと作ってしまいましょう。
const HTTP_STATUS_CODE_OK = 200;
const HTTP_STATUS_CODE_CONFLICT = 409;
const TO_NS = 1000 * 1000 * 1000;
const property = PropertiesService.getUserProperties();
/**
* 起動関数。
*
* HealthPlanet及びGoogleFitとの認証を行う必要があるため初回は手動で実行し
* 下記の手順に従って認証を完了させる。認証完了後はトリガー起動での定期的な実行が可能。
* 1. 起動すると実行ログにHealthPlanet認証用URLが出力されるので、ブラウザでアクセスする
* 2. HealthPlanetのログイン画面が表示されるのでログインする
* 3. HealthPlanetのアクセス許可画面が表示されるのでアクセスを許可する
* 4. Google Driveの「現在、ファイルを開くことができません。」というエラー画面が表示される
* 5. HealthPlanetがGASから引き渡したリダイレクトURLのパラメータ部分をカットしていることが
* エラーの原因なので、下記の通りSTATE部分を補う(実行ログからコピー)
* 誤)https://script.google.com/macros/d/{SCRIPT ID}/usercallback?code={CODE}
* 正)https://script.google.com/macros/d/{SCRIPT ID}/usercallback?code={CODE}&state={STATE}
* 6. Success!と表示されれば登録が完了。HealthPlanetの連携アプリ一覧にツールが表示される
* 7. 続いて実行ログにGoogleFit認証用URLが出力されるので、ブラウザでアクセスする
* 8. 画面の指示に従って認証を完了させる
*
* HealthPlanet及びGoogleFitとのデータ削除を含む接続解除手順は下記の通り。
* 1. removeHealthDataを実行してGoogleFitからデータセットを削除する
* (dataNameのコメントアウトで削除対象を切り替える)
* 2. removeGFDataSourceを実行してGoogleFitからデータソースを削除する
* (dataNameのコメントアウトで削除対象を切り替える)
* 3. listGFDataSourceを実行してデータソースが残っていないことを確認する
* 4. GoogleFitアプリにて、このプログラムからの接続を解除する
* 5. HealthPlanetにて、このプログラムからの接続を解除する
* 6. logoutFromServiceを実行して、GoogleFit及びHealthPlanetから切断する
*/
const run = () => {
const hpService = getHPService();
let healthData;
// HealthPlanetへの認証が完了していない場合は認証用URLを出力して終了する
if (hpService.hasAccess()) {
console.log("HealthPlanet is ready");
healthData = fetchHealthData(hpService);
} else {
console.log("Please access the URL below to complete your authentication with HealthPlanet");
console.log(hpService.getAuthorizationUrl());
console.log("If you get a Google Drive error, please add the following parameter to the URL to access it");
console.log(/(&state=.*?)&/.exec(hpService.getAuthorizationUrl())[1]);
}
const gfService = getGFService();
// GoogleFitへの認証が完了していない場合は認証用URLを出力して終了する
if (gfService.hasAccess()) {
console.log("GoogleFit is ready");
createGFDataSource(gfService, googleFit.weight);
createGFDataSource(gfService, googleFit.fat);
postHealthData(gfService, googleFit.weight, healthData);
postHealthData(gfService, googleFit.fat, healthData);
} else {
console.log("Please go to the URL below to complete the authentication with GoogleFit");
console.log(gfService.getAuthorizationUrl());
}
}
/**
* HealthPlanet用の認証サービスを取得する。
*/
const getHPService = () => {
return OAuth2.createService(healthPlanet.serviceName)
.setAuthorizationBaseUrl(healthPlanet.setAuthorizationBaseUrl)
.setTokenUrl(healthPlanet.tokenUrl)
.setClientId(healthPlanet.clientId)
.setClientSecret(healthPlanet.clientSecret)
.setCallbackFunction(healthPlanet.callback)
.setPropertyStore(property)
.setScope(healthPlanet.scope)
.setGrantType(healthPlanet.grantt);
}
/**
* HealthPlanetのOAuth認証が完了したときに呼ばれるコールバック関数。
*/
const hpAuthCallback = (request) => {
const hpService = getHPService();
const isAuthorized = hpService.handleCallback(request);
if (isAuthorized) {
return HtmlService.createHtmlOutput("Success!");
} else {
return HtmlService.createHtmlOutput("Denied.");
}
}
/**
* HealthPlanetから体重データを取得する。
*/
const fetchHealthData = (service) => {
const payload = {
"access_token": service.getAccessToken(),
"date": healthPlanet.payloadDate,
"tag": healthPlanet.payloadTag,
"from": dayjs.dayjs().subtract(3, "month").format("YYYYMMDDHHmmss")
};
const options = {
"method": "POST",
"payload": payload,
};
const response = UrlFetchApp.fetch(healthPlanet.innerscanUrl, options);
return JSON.parse(response);
}
/**
* GoogleFit用の認証サービスを取得する。
*/
const getGFService = () => {
return OAuth2.createService(googleFit.serviceName)
.setAuthorizationBaseUrl(googleFit.setAuthorizationBaseUrl)
.setTokenUrl(googleFit.tokenUrl)
.setClientId(googleFit.clientId)
.setClientSecret(googleFit.clientSecret)
.setCallbackFunction(googleFit.callback)
.setPropertyStore(property)
.setScope(googleFit.scope)
.setParam("login_hint", Session.getActiveUser().getEmail())
.setParam("access_type", "offline")
.setParam("approval_prompt", "force");
}
/**
* GoogleFitのOAuth認証が完了したときに呼ばれるコールバック関数。
*/
const gfAuthCallback = (request) => {
const gfService = getGFService();
const isAuthorized = gfService.handleCallback(request);
if (isAuthorized) {
return HtmlService.createHtmlOutput("Success!");
} else {
return HtmlService.createHtmlOutput("Denied.");
}
}
/**
* GoogleFitにデータソースを作成する。
*/
const createGFDataSource = (service, dataName) => {
const payload = {
"dataStreamName": "TanitaScales",
"type": "raw",
"application": {
"detailsUrl": "http://example.com",
"name": "GoogleFit Transmitter",
"version": "1"
},
"dataType": {
"field": [
{
"name": dataName === googleFit.weight ? "weight" : "percentage",
"format": "floatPoint"
}
],
"name": dataName
},
"device": {
"manufacturer": "TANITA",
"model": "RD-800",
"type": "scale",
"uid": "1000001",
"version": "1.0"
}
};
const options = {
"headers": {
"Authorization": "Bearer " + service.getAccessToken()
},
"muteHttpExceptions": true,
"method": "POST",
"contentType": "application/json",
"payload": JSON.stringify(payload, null, 2)
};
const response = UrlFetchApp.fetch(googleFit.dataSourceUrl, options);
if (response.getResponseCode() === HTTP_STATUS_CODE_CONFLICT) {
console.log("GoogleFit data source %s is ready", dataName);
} else if (response.getResponseCode() === HTTP_STATUS_CODE_OK) {
const json = JSON.parse(response);
if (!property.getProperty(dataName)) {
property.setProperty(dataName, json.dataStreamId);
}
console.log("GoogleFit data source %s has been created successfully", dataName);
} else {
console.log("Failed to create GoogleFit data source %s", dataName);
console.log(response.getResponseCode());
console.log(response.getContentText());
}
}
/**
* GoogleFitへヘルスデータ(体重・体脂肪率)を登録する。
*/
const postHealthData = (service, dataName, healthData) => {
// 登録するデータセットの最小時刻と最大時刻を算出する
const minTime = Math.min.apply(null, healthData.data.map((elem) => { return elem.date; })).toString();
const maxTime = Math.max.apply(null, healthData.data.map((elem) => { return elem.date; })).toString();
const minTimeNs = convertUnixStartTime(minTime);
const maxTimeNs = convertUnixEndTime(maxTime);
payload = {
minStartTimeNs: minTimeNs,
maxEndTimeNs: maxTimeNs,
dataSourceId: property.getProperty(dataName),
point: []
};
healthData.data.map((elem) => {
if ((dataName === googleFit.weight ? BODY_WEIGHT : BODY_FAT) === elem.tag) {
payload.point.push({
startTimeNanos: convertUnixStartTime(elem.date),
endTimeNanos: convertUnixEndTime(elem.date),
dataTypeName: dataName,
value: [{ fpVal: elem.keydata }]
});
}
});
const options = {
"headers": {
"Authorization": "Bearer " + service.getAccessToken()
},
"muteHttpExceptions": true,
"method": "PATCH",
"contentType": "application/json",
"payload": JSON.stringify(payload, null, 2)
};
const response = UrlFetchApp.fetch(
Utilities.formatString(
"%s/%s/datasets/%s",
googleFit.dataSourceUrl,
property.getProperty(dataName),
`${minTimeNs}-${maxTimeNs}`
),
options
);
if (response.getResponseCode = HTTP_STATUS_CODE_OK) {
console.log("GoogleFit datasets %s have been registered successfully", dataName);
console.log(response.getContentText());
} else {
console.log("Failed to register GoogleFit datasets %s", dataName);
console.log(response.getResponseCode());
console.log(response.getContentText());
}
}
/**
* 指定した日付(文字列)の開始時刻をナノ秒精度のUNIX時間に変換する。
*/
const convertUnixStartTime = (dateString) => {
return dayjs.dayjs(dateString, "YYYYMMDDHHmm").startOf("date").unix() * TO_NS;
}
/**
* 指定した日付(文字列)の終了時刻をナノ精度のUNIX時間に変換する。
*/
const convertUnixEndTime = (dateString) => {
return dayjs.dayjs(dateString, "YYYYMMDDHHmm").endOf("date").unix() * TO_NS;
}
/**
* GoogleFitのデータソースを列挙する(開発時用、単独で実行する)。
*/
const listGFDataSource = () => {
const gfService = getGFService();
const options = {
"headers": {
"Authorization": "Bearer " + gfService.getAccessToken()
},
"muteHttpExceptions": true,
"method": "GET"
};
const response = UrlFetchApp.fetch(googleFit.dataSourceUrl, options);
console.log(response.getResponseCode());
console.log(response.getContentText());
}
/**
* GoogleFitのデータソースを削除する(開発時用、単独で実行する)。
* データを削除するには事前に全てのヘルスデータが削除されている必要がある。
*/
const removeGFDataSource = () => {
// 処理したいデータに合わせてコメントアウトを入れ替える
const dataName = googleFit.weight;
// const dataName = googleFit.fat;
const gfService = getGFService();
const options = {
"headers": {
"Authorization": "Bearer " + gfService.getAccessToken()
},
"muteHttpExceptions": true,
"method": "DELETE"
};
const response = UrlFetchApp.fetch(
Utilities.formatString(
"%s/%s",
googleFit.dataSourceUrl,
property.getProperty(dataName)
),
options
);
console.log(response.getResponseCode());
console.log(response.getContentText());
}
/**
* GoogleFitからヘルスデータを削除する(開発時用、単独で実行する)。
*/
const removeHealthData = () => {
// 処理したいデータに合わせてコメントアウトを入れ替える
const dataName = googleFit.weight;
// const dataName = googleFit.fat;
const gfService = getGFService();
const listOptions = {
"headers": {
"Authorization": "Bearer " + gfService.getAccessToken()
},
"muteHttpExceptions": true,
"method": "GET"
};
const listResponse = UrlFetchApp.fetch(
Utilities.formatString(
"%s/%s/dataPointChanges",
googleFit.dataSourceUrl,
property.getProperty(dataName),
),
listOptions
);
console.log(listResponse.getResponseCode());
console.log(listResponse.getContentText());
const json = JSON.parse(listResponse);
// 登録されたデータセットの最小時刻と最大時刻を算出する
const minTime = Math.min.apply(null, json.insertedDataPoint.map((elem) => { return elem.startTimeNanos; })).toString();
const maxTime = Math.max.apply(null, json.insertedDataPoint.map((elem) => { return elem.endTimeNanos; })).toString();
const deleteOptions = {
"headers": {
"Authorization": "Bearer " + gfService.getAccessToken()
},
"muteHttpExceptions": true,
"method": "DELETE"
};
const deleteResponse = UrlFetchApp.fetch(
Utilities.formatString(
"%s/%s/datasets/%s",
googleFit.dataSourceUrl,
property.getProperty(dataName),
`${minTime}-${maxTime}`
),
deleteOptions
);
console.log(listResponse.getResponseCode());
console.log(listResponse.getContentText());
}
/**
* プロパティにGoogleFitのデータソース名をセットする(開発時用、単独で実行する)。
* listGFDataSourceで取得したデータソース名をsetProperty第2引数に指定する。
* 各サービスのサイトで接続を解除した後に実行する。
*/
const setDataourceToProperty = () => {
console.log(property.getProperty(googleFit.weight));
console.log(property.getProperty(googleFit.fat));
property.setProperty(googleFit.weight, "(weight datasource name)");
property.setProperty(googleFit.fat, "(fat datasource name)");
}
/**
* HealthPlanetとGoogleFitとのOAuth認証を解除する(開発時用、単独で実行する)。
* 各サービスのサイトで接続を解除した後に実行する。
*/
const logoutFromService = () => {
getHPService().reset();
getGFService().reset();
property.deleteAllProperties();
console.log("Logged out successfully")
}
転送プログラムの使い方
今回のプログラムは CUI アプリケーションなので、OAuth 認証を行うために初回だけ手動操作を行う必要があります。run()
メソッドを実行したら下記の手順に従って操作を行ってください。2回目以降は run()
メソッドを実行するだけでデータの取得と転送が自動的に行われるようになります。
- 実行ログに HealthPlanet 認証用 URL が出力されるので、ブラウザでアクセスする
- HealthPlanet のログイン画面が表示されるのでログインする
- HealthPlanet のアクセス許可画面が表示されるのでアクセスを許可する
- Google Drive の「現在、ファイルを開くことができません。」というエラー画面が表示される
- ブラウザのアドレス欄に表示された URL に対して STATE 部分を補ってアクセス(STATE は実行ログに表示されているものを使用)
誤)https://script.google.com/macros/d/{SCRIPT ID}/usercallback?code={CODE}
正)https://script.google.com/macros/d/{SCRIPT ID}/usercallback?code={CODE}&state={STATE} - Success! と表示されれば登録が成功。HealthPlanet の連携アプリ一覧にツールが表示される
- 続いて実行ログに GoogleFit 認証用 URL が出力されるので、ブラウザでアクセスする
- 画面の指示に従って認証を完了させる
- 認証完了後、再び
run()
メソッドを実行する
Google の OAuth 画面で警告が表示されますが、そのまま進めてアクセスの許可を行ってください。Google 社の審査を受ければこの警告画面は表示されなくなりますが、個人用途のため審査を受けずに使用します。
実行後、2~3分待って GoogleFit を覗いてみると・・・ドヤァ!
毎回手動実行するのも面倒なので、時間起動トリガーを使って1日1回動くように設定することをオススメします!
苦労したところ
HealthPlanet の OAuth 認証エラー
これ、正直めちゃくちゃハマりました。他にも結構ハマっている人がいそうだったので書き残しておきます。
何かというと、HealthPlanet の OAuth 認証で画面操作を行ったあとに何故か GoogleDrive のエラーページに飛ばされて認証が完結しない問題です。加えて、OAuth2 ライブラリを使った場合に呼び出されるはずのコールバック関数が発火しない。何故だ・・・😱
ちょうど 転送プログラムの使い方 のステップ4の部分です。実は、GAS の関数(コールバック)を外部から起動するには STATE というトークンが必要なのですが、これが原因でした。外部から不正な実行を防ぐための仕様ですね。
GAS から外部サービスを呼び出し、その後にコールバック関数を呼び出したいような場合は、StateTokenBuilder を使ってトークンを発行し、それを使ってアクセスする形になります。前述の OAuth2 ライブラリはこの辺りも含めてよしなにやってくれていたのであまり意識できていませんでした。
で、よくよく見ていくと HealthPlanet の OAuth API 呼び出し時に redirect_uri
として STATE パラメータ付き URL を指定しているのに、GoogleDrive のエラーが表示時の URL には STATE パラメータが含まれていないではないですか・・・。
https://script.google.com/macros/d/{SCRIPT ID}/usercallback?code={CODE}
どうやら、HealthPlanet は redirect_uri
に指定した URL パラメータをカットする仕様になっていて、その結果として STATE パラメータ無しでコールバックを呼び出そうとしていたので、GAS 側では関数が特定できずにエラーになっていたようです。うーん、この仕様は・・・。
そこに対応したのが 転送プログラムの使い方 のステップ5の手順で、ハンドで STATE パラメータを補っています。ここは GAS と HealthPlanet の仕様のかみ合わない部分なので自動化は妥協しました。
https://script.google.com/macros/d/{SCRIPT ID}/usercallback?code={CODE}&state={STATE}
ちなみに、GAS 以外の環境では URL パラメータを使用しない形で実装すれば問題なく動作するはずです。
GoogleFit API
これは単純に仕様を理解するのに時間がかかったというだけですが、登録するデータごとにデータソースを作成する必要があり、そのデータ種別ごとに持ち方も変わってくるので、一定程度は API 仕様書を読み込む必要があると感じました(当たり前のことをサラッと言ってやりました)。
またデータには依存関係があるので、接続解除やデータ削除には順番があったりします。ここでは細かくは述べませんが、Transmitter.gs の冒頭部分のコメントを書いているので、興味がある方は読んでみてください。ただ、この辺りは少しややこしいので詳しくない人は触らないほうが無難です。
まとめ
今回は完全に趣味の話でしたが、いかがだったでしょうか。地味に面倒だった体重データの手入力から開放され、体重計に乗るだけで OK になったので、非常に快適になりました(自己満足を含む)。
最初は GoogleFit に対応した体重計を買ってしまおうかなとも考えていたので、実行環境も含めて無料で実現できたことも懐事情的にありがたかったです。
こういった身の回りのちょっとした困りごとを自分で解決できるのはエンジニアの特権なので、自称エンジニアをやっていてよかったと改めて思いました!
参考リンク
当記事を書くに際して @hirotow さんの投稿を大いに参考にさせていただきました!
ありがとうございました🙏