Help us understand the problem. What is going on with this article?

タニタHealthPlanet(体組成計RD-907)のデータをFitbitとGoogle Fitに同期する

要点

  • タニタ体組成計RD-907で毎日体組成を測定しているが、fitbitやGoogleFitにもデータを同期したい
  • Google Apps Scriptを毎日定期実行し、タニタ体組成計のデータを取得できるHealthPlanet APIとfitbit Web-APIを利用してHealthPlanet→fitbitのデータ転送を実現
  • タニタの体組成計のデータはスプレッドシートに保存
  • fitbit→GoogleFitはAndroidアプリのFitToFitを利用

使用ツール

ツールの用意

1. HealthPlanet API利用の準備

HealthPlanet APIを利用するにあたって、HealthPlanetにてClient IDとClient secretを取得する必要がある。
こちらのページを参考にさせていただいた。

HealthPlanet APIのページにて下図のようにClient IDとClient secretを取得できれば完了。下図では該当部は白抜きしている。
image.png

2. Fitbit API利用の準備

HealthPlanetと同様にFitbitのAPI利用登録を行う。
こちらのページを参考にさせていただいた。
Fitbit devにてAPIの利用登録ができれば完了。
image.png

3. Google Apps ScriptへのOAuthライブラリの登録

API利用のためのOAuth周りについては全くの無知なのでライブラリを利用する。
こちらのOauth2ライブラリを利用した。
Google Apps Scriptにライブラリを登録するにはこちらのコードをライブラリに追加すれば良い。

1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF

image.png

4. スプレッドシートの用意

Google Apps Scriptと連携したスプレッドシートはこのようなフォーマットにした。
image.png

実装

1. OAuthに必要な情報

code.js
//HealthPlanet用
var HP_CLIENT_ID = '*****'; //Health Planet の Client ID
var HP_CLIENT_SECRET = '*****'; //Health Planet の Client Secret
var HP_OAUTH_URL = 'https://www.healthplanet.jp/oauth/auth';
var HP_TOKEN_URL = 'https://www.healthplanet.jp/oauth/token';
var HP_CODE = '*****'; //リダイレクトで返ってきたCODEを手動でコピペ
var HP_ACCESS_TOKEN = '*****'; //取得したアクセストークンを手動でコピペ
var HP_REFRESH_TOKEN = '*****'; //使用しない?

//Fitbit用
var FB_CLIENT_ID = '*****'; //Fitbit の Client ID
var FB_CLIENT_SECRET = '*****'; //FitBit の Client Secret
var FB_OAUTH_URL = 'https://www.fitbit.com/oauth2/authorize';
var FB_TOKEN_URL = 'https://api.fitbit.com/oauth2/token';
var FB_USERID = '*****'; //Fitbit devに記載されている

2. HealthPlanetからデータを取得する

今回同期したいのは体重と体脂肪率だけだが、一通りデータを取得してスプレッドシートに追記する。既にスプレッドシートにデータが有るかどうかは日付で判定する。HealthPlanetから直近の2ヶ月分のデータを取得する。OAuth処理が終わったあとはhpGetData()を定期実行する。

OAuth周りのことがよくわからないので、HealthPlanetの場合はライブラリがうまく機能せず(具体的にはCallback関数が呼ばれない、リフレッシュトークンを利用したアクセストークン更新方法がわからない)、アクセスコードやアクセストークンを手動でフェッチして「1. OAuthに必要な情報」に転記している。
良い方法があったら教えていただきたい。

code.js
// 本来であればリフレッシュトークンを利用したアクセストークンの更新によってhasAccessがtrueになるはずだが、HealthPlanetの仕様のために常にfalse
function hpRun() {
  var service = hpGetService();

  // アクセストークンが有効か調べる
  if (service.hasAccess()) { 
    // 本当はここに実行コードを書きたいが、hasAccessがtrueにならない
    Logger.log('hp hasAccess OK')
  }else{
    var authorizationUrl = service.getAuthorizationUrl();
    Logger.log(authorizationUrl);
  }
}

// Callbackで戻ってきたときに呼ばれる→HealthPlanetでは呼ばれない
function hpAuthCallback(request) {
  logger.Log('hpAuthCallback')
  var service = hpGetService();
  var isAuthorized = service.handleCallback(request);
  if (isAuthorized) {
    return HtmlService.createHtmlOutput('Success!');
  } else {
    return HtmlService.createHtmlOutput('Denied.');
  }
}


// Authorizationをリセット
function hpClearService(){
  hpGetService().reset();
}


// OAuth2の設定
function hpGetService(){
  return OAuth2.createService('HealthPlanet') // 任意の名前
  .setAuthorizationBaseUrl(HP_OAUTH_URL)
  .setTokenUrl(HP_TOKEN_URL)
  .setClientId(HP_CLIENT_ID)
  .setClientSecret(HP_CLIENT_SECRET)
  .setCallbackFunction('hpAuthCallback')
  .setPropertyStore(PropertiesService.getUserProperties())
  .setScope('innerscan')
}

/*
  CODEを元にアクセストークンを取得する
*/
function hpGetToken(){
    url = HP_TOKEN_URL;

    var payload = {
      'client_id' : HP_CLIENT_ID, 
      'client_secret' : HP_CLIENT_SECRET,
      'redirect_uri' : 'https://www.healthplanet.jp/success.html',
      'code' : HP_CODE,
      'grant_type' : 'authorization_code'
    };

    var options = {
      'method' : 'POST',
      'payload': payload,
    };

    // トークンの取得
    var response = UrlFetchApp.fetch(url, options);
    var data = JSON.parse(response)
    Logger.log(data)

}

/*
  HealthPlanetから直近の2ヶ月分のデータを取得する
*/
function hpGetData(){

    // スプレッドシートを取得する
    var hpSS = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("HealthPlanet");
    var hpSS_Date = hpSS.getRange(2,1,hpSS.getLastRow(),1).getValues();

    // スプレッドシート上のデータの日付をフォーマットしてリスト化
    hpDateList = []
    for(var i = 0; i < hpSS_Date.length; i++){
      var ssDateFormatted = new Date(hpSS_Date[i]);
      hpDateList.push(ssDateFormatted.getYear() + '/' + ('0' + String(Number(ssDateFormatted.getMonth()) + 1)).slice(-2) + '/' + ('0' + ssDateFormatted.getDate()).slice(-2));
    }

    url = 'https://www.healthplanet.jp/status/innerscan.json';

    // 2ヶ月前の日付を取得
    fromDate = new Date()
    fromDate.setMonth(fromDate.getMonth() - 2)
    fromDate = Utilities.formatDate(fromDate, 'Asia/Tokyo', 'yyyyMMdd') + '000000';

    // payloadに必要な情報
    /*
    date (必須)
    from to の日付タイプを指定する。
    0 : 登録日付
    1 : 測定日付

    from
    取得期間 from を指定する。yyyyMMddHHmmss 形式で指定し、必ず from < to でなければならない
    未指定の場合は3ヶ月前が指定される

    to
    取得期間 to を指定する。yyyyMMddHHmmss 形式で指定し、必ず from < to でなければならない
    未指定の場合は現時刻が指定される

    tag
    取得する測定部位を指定する。未指定の場合はデフォルトの値を取得する。カンマ区切りで指定する
    6021 : 体重 (kg)
    6022 : 体脂肪率 (%)
    6023 : 筋肉量 (kg)
    6024 : 筋肉スコア
    6025 : 内臓脂肪レベル2(小数点有り、手入力含まず)
    6026 : 内臓脂肪レベル(小数点無し、手入力含む) → 使用されていない
    6027 : 基礎代謝量 (kcal)
    6028 : 体内年齢 (才)
    6029 : 推定骨量 (kg)
    */
    var payload = {
      'access_token' : HP_ACCESS_TOKEN, 
      'date' : '1',
      'tag' : '6021,6022,6023,6024,6025,6027,6028,6029',//6026を除く
      'from' : fromDate
    };

    var options = {
      'method' : 'POST',
      'payload': payload,
    };

    // データを取得する
    var response = UrlFetchApp.fetch(url, options);
    var data = JSON.parse(response); //このdataは日付に対して6021~6029のうちのデータを1つもつため、1日分のデータが分かれている

    oneday_datalist = []; // 1日分のデータを保持するリスト
    prev_date = ''; // 前日の日付

    // 取得したデータのうち、スプレッドシートの日付リストhpDateListに日付が存在しないものをスプレッドシートに追記
    for(var i = 0; i < data['data'].length; i++){
      date = data['data'][i].date;
      dateTimeFormatted = date.substr(0,4) + '/' + date.substr(4,2) + '/' + date.substr(6,2) + ' ' + date.substr(8,2) + ':' + date.substr(10,2)
      dateFormatted = date.substr(0,4) + '/' + date.substr(4,2) + '/' + date.substr(6,2)

      // 日付で既存有無の判断
      if(hpDateList.indexOf(dateFormatted)==-1.0){
        if(prev_date == ''){ // for文の初回のみ呼ばれる
          oneday_datalist.push(dateTimeFormatted);
        }else if(date != prev_date){ // dataの日付が変わった場合にoneday_datalistにまとめたデータをスプレッドシートに追記する
          hpSS.appendRow(oneday_datalist);
          oneday_datalist = [];
          oneday_datalist.push(dateTimeFormatted);
        }

        if(oneday_datalist.length < 9){ // 同じ日時に複数回測定していると9行目以降に追記されるが、そのデータは不要なので無視する
          oneday_datalist.push(data['data'][i].keydata);
        };
        prev_date = date;
      };
    };

    // 日付で昇順に並び替え
    ssHP.getRange(2,1,ssHP.getLastRow() - 1,ssHP.getLastColumn()).sort({column:1, ascending:true})

}



3. Fitbitにデータを転送する

Fitbitから過去1年分の体重データを取得し、Fitbitに体重データが無いものをHealthPlanetから転送する。判定は日付で実行している。fbRun()を定期実行する。

code.js
function fbRun() {
  var service = fbGetService();

  // アクセストークンを持ってるか調べる
  if (service.hasAccess()) {
    Logger.log('fbRun already authorized! Start processing!');

    // 今日から1年間遡ってfitbitの体重データをリストで取得する
    fbWeightList = fbGetWeightList(service);

    /*
      HealthPlanetのデータのうち、fitbitのデータに存在しないものを日付を元に探す
    */
    // HealthPlanetのスプレッドシートからデータを取得する
    var hpSS = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("HealthPlanet");
    var hpData = hpSS.getRange(2,1,hpSS.getLastRow() - 1,hpSS.getLastColumn()).getValues();

    // fitbitにデータの存在する日付をリストで保有する
    fbDateList = [];
    for(var i = 0; i < fbWeightList.length; i++){
      fbDateList.push(fbWeightList[i]['date']);
    }

    // HealthPlanetのスプレッドシートデータの日付がfitbitデータの日付にあるかどうかを判定する
    for(var i = 0; i < hpData.length; i++){
      var hpDate = new Date(hpData[i][0]);
      hpDateFormatted = hpDate.getYear() + '-' + ('0' + String(Number(hpDate.getMonth()) + 1)).slice(-2) + '-' + ('0' + hpDate.getDate()).slice(-2);
      hpTimeFormatted = ('0' + hpDate.getHours()).slice(-2) + ':' + ('0' + hpDate.getMinutes()).slice(-2) + ':00';

      // fitbitに存在しない場合
      if(fbDateList.indexOf(hpDateFormatted)==-1.0){
        fbPostWeight(service, hpDateFormatted, hpTimeFormatted, hpData[i][1], hpData[i][2]); // 日付、時刻、体重、体脂肪率を引き数で渡す
        hpSS.getRange(i + 2, 10).setValue(new Date()); // fitbitに同期した日付をスプレッドシートに追記する
      }
    }

  }else{
    var authorizationUrl = service.getAuthorizationUrl();
    Logger.log(authorizationUrl);
  }
}

//Callbackで戻ってきたときに呼ばれる
function fbAuthCallback(request) {
  var service = fbGetService();
  var isAuthorized = service.handleCallback(request);
  if (isAuthorized) {
    return HtmlService.createHtmlOutput('Success!');
  } else {
    return HtmlService.createHtmlOutput('Denied.');
  }
}

// リセット
function fbClearService(){
  fbGetService().reset();
}

// OAuth2の設定
function fbGetService(){
  return OAuth2.createService('Fitbit') // 任意の名前
  .setAuthorizationBaseUrl(FB_OAUTH_URL)
  .setTokenUrl(FB_TOKEN_URL)
  .setClientId(FB_CLIENT_ID)
  .setClientSecret(FB_CLIENT_SECRET)
  .setCallbackFunction('fbAuthCallback')
  .setPropertyStore(PropertiesService.getUserProperties())
  .setScope('weight')
  .setTokenHeaders({'Authorization': 'Basic ' + Utilities.base64Encode(FB_CLIENT_ID + ':' + FB_CLIENT_SECRET)});
}


function logRedirectUri() {
  Logger.log(OAuth2.getRedirectUri());
}


// 今日の日付から1年分遡って体重データを取得する
function fbGetWeightList(service){
  dateList = [];
  weightList = [];

  // 1年分の日付を1ヶ月毎に分けてリスト化
  for(var i = 0; i < 12; i++){
    fetchDate = new Date();
    fetchDate.setMonth(fetchDate.getMonth() - i)
    fetchDate = Utilities.formatDate(fetchDate, 'Asia/Tokyo', 'yyyy-MM-dd');
    dateList.push(fetchDate);
  }

  // 1ヶ月分ごとにデータを取得
  for(var i = 0; i < dateList.length; i++){
    url = 'https://api.fitbit.com/1/user/' + FB_USERID + '/body/log/weight/date/' + dateList[i] + '/1m.json';

    var headers = {
      'Authorization': 'Bearer ' + service.getAccessToken()
    };

    var options = {
      'method' : 'GET',
      'headers' : headers,
      'muteHttpExceptions': true
    };

    // データ取得
    var response = UrlFetchApp.fetch(url, options);
    var data = JSON.parse(response);
    Array.prototype.push.apply(weightList, data['weight'])
  }

  return weightList
}

//渡された日時の体重と体脂肪率をfitbitに同期する
function fbPostWeight(service, date, time, weight, fat){
  urlWeight = 'https://api.fitbit.com/1/user/' + FB_USERID + '/body/log/weight.json';
  urlFat = 'https://api.fitbit.com/1/user/' + FB_USERID + '/body/log/fat.json';

  // アクセストークンをheadersで渡す
  var headers = {
    'Authorization': 'Bearer ' + service.getAccessToken()
  };

  var payloadWeight = {
    'weight' : weight,
    'date' : date,
    'time' : time
  };

  var payloadFat = {
    'fat' : fat,
    'date' : date,
    'time' : time
  };

  var optionsWeight = {
    'method' : 'POST',
    'payload': payloadWeight,
    'headers' : headers,
    'muteHttpExceptions': true
  };

  var optionsFat = {
    'method' : 'POST',
    'payload': payloadFat,
    'headers' : headers,
    'muteHttpExceptions': true
  };

  // データ投稿
  var responseWeight = UrlFetchApp.fetch(urlWeight, optionsWeight);
  Logger.log(JSON.parse(responseWeight))

  var responseFat = UrlFetchApp.fetch(urlFat, optionsFat);
  Logger.log(JSON.parse(responseFat))
}

4. Google Apps Scriptを定期実行する

hpGetData()とfbRun()を1日に1度定期実行する。スプレッドシートのデータ数が増えすぎても困るので、あとは1年より前のデータは自動削除するスクリプトを作れば完璧。

hirotow
hirotowの恥晒し。 ハードウェアの道を選んだにも関わらず30歳にして機械学習を学ぶ必要性に駆られ、窮地に立たされているソフトウェアNewbie。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした