毎日の運動量と睡眠状態をTwitterに垂れ流してモニタリングする。

May 23, 2021

はじめに

最近、このようなご時世である故に、在宅勤務で一歩も外に出ない日も珍しくない。 意識しないと運動不足になりかねない。最近、体の健康管理を目的にfitbitというスマートウォッチを利用している。 このfitbitはAPIが公開されており、スマートウォッチのアプリを作成したり、デバイスの情報を取得することができる。 そこで、運動状況と睡眠状態のモニタリングのため、fitbitで取得した情報を毎日ツイートすることにした。

fitbitとは…

fitbitとは、アメリカのfitbit.inc が販売している、スマートウォッチである。これを手首に装着することで運動中の心拍数や酸素濃度などを測定できる。このような情報から、運動による消費カロリー、心拍数の変化、睡眠の質の記録などが可能なものである。私が使用しているFitbit Versa 3 では、それらに加えて、GPSでワークアウトを管理できたり、将来的にSuicaに対応し、非接触決済端末としても使えるようになるそうだ。

fitbitのAPIについて…

fitbitはAPIでデバイスの情報を取得したり、fitbit専用の独自アプリを開発ができるようになっている。このAPIを使えば、fitbitで取得されている1日の消費カロリーや睡眠時間などを取得できる。既にfitbitを利用しているのであればSwagger UI形式で、Fitbit Web API を試すことができる。このAPIは、OAuth2.0での認可を要求し、認可を取得できれば、アクセストークンとリフレッシュトークンが取得できる。

ClientIDなどが記載されたページの下部にある OAuth 2.0 tutorial page で実際に試すことができる。リフレッシュトークンの期限が切れると再度認可取得からやる必要があり面倒だが、OAuth2.0では、期限内であればリフレッシュトークンからアクセストークンを取得することができる。

どうやってTweetするんだっけ?

fitbit APIで取得した情報をツイートするためには、少なくとも、以下の4点をクリアしないといけない。

  1. Twitterのdeveloper登録を済ませて、TwitterのAPIを叩くためのアクセストークン(無期限)を取得していないといけない。
  2. fitbitのAPIのアクセストークンとリフレッシュトークン(期限がある)を取得する必要がある。
  3. fitbitのAPIのアクセストークンを定期的に更新する仕組みがある必要がある。
  4. 定期的にfitbitAPIを叩いて取得した値を用いてメッセージを生成し、Twitterに投稿する仕組みが必要になる。

Twitterのdeveloper登録を済ませて、TwitterのAPIを叩くためのアクセストークン(無期限)を取得していないといけない。

に関しては、TwitterのDeveloperポータルからアプリの登録とトークンの発行は可能である。ただし、TwitterのAPIを悪用する者が多いからか、作成するアプリの概要、どんなエンドポイントを叩くかなどを英語で作文し、Twitterの審査を受ける必要がある。私も頑張って英語で作りたいものを説明したが、もっと詳しく教えて欲しいとの旨のメールを受け取っている…

そのメールがGoogle翻訳で自動的に日本語になっていたのか、返信を日本語で返したら再度同じメールをもらう、ということもやらかしていたので、アプリが承認されるまで時間がかかってしまった。

最終的に以下のように、ただ毎日00:10に決まったフォーマットのツイートをしたいだけなんだ、というメールを送りつけたことにより、アプリは承認された。

Post to your own (@ myblackcat7112) account once a day, the result of hitting the Fitbit API to check your exercise habits, formatted as follows:

Today's exercise from fitbit
Sitting time: 662 minutes
Light exercise time: 127 minutes
Active exercise time: 49 minutes
Time for strenuous exercise: 78 minutes
Today's steps: 8650 steps (5.893km)
Basal metabolism: 1402 kcal
Calories burned: 2727 kcal

https://twitter.com/myblackcat7112/status/1377597776662470657?s=20

I will post to my account, @myblackcat7112  at 00:10:00 everydays.
I want to use the API to post an auto-generated message to Twitter.

この承認を得る作業が、今回、最も難しかったと言っても過言じゃない笑

fitbitのAPIのアクセストークンとリフレッシュトークン(期限がある)を取得する必要がある。

に関しては、上述したOAuth 2.0 tutorial pageで簡単に取得できるので問題ない。

実際の認可取得画面のURLを生成するページ

詳細に関してはこちらの記事も参考になると思う。

Pythonでfitbit APIから心拍数を取得してみよう!

  1. fitbitのAPIのアクセストークンを定期的に更新する仕組みがある必要がある。
  2. 定期的にfitbitAPIを叩いて取得した値を用いてメッセージを生成し、Twitterに投稿する仕組みが必要になる。

に関しては少しコードを書く必要がある。今回はAWS のLambdaを用いて、Tweetすることにした。

また、アクセストークンの更新は、AWSのSecret Managerに保存されているアクセストークン、リフレッシュトークンを、Tweetを実行する前のタイミングに更新するLambdaを実行することで解決することにした。

Secret Manager ?

AWSのSecret Manager は DBの認証情報やAPIのアクセストークンなどを安全に保管しておくことができるものである。

シークレットあたり $0.40 / 月

10,000 回の API コールあたり $0.05

とあるので、あまり多くを保存するのはコストが増えるだけではあるが、賢く使えば認証情報を安全に保管したりアクセストークンなどのローテション(自動更新)ができるものである。

今回は、TwitterAPIとFitbitAPIのAPI情報を格納することにする。

使い方は簡単で、保存したい値の名前(key)と値(value)を保存するだけである。トークンなどのローテーションは、SecretManagerの更新権限を、持ち実際に更新する処理が書かれているAWS Lambdaを指定してあげることで可能になる。(一応設定してあるが、タイミングの問題かあまりうまくいっていないので、ここで設定しているLambdaを定期実行するというださいことをやってしまっている…)

SecretManagerの画面

ローテーションの設定画面

SecretManagerからLambdaを呼び出すのに、以下のような設定を画面から行なったがうまくいかなかった。(原因はいまだによくわからないが、権限が足らなかったのかもしれない?)

"Statement": [
    {
      "Sid": "SecretsManagerAccess",
      "Effect": "Allow",
      "Principal": {
        "Service": "secretsmanager.amazonaws.com"
      },
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:ap-northeast-1:XXXXXXX:function:fitbit-api-token-refresh"
    },

結局、AWS Secrets Manager のシークレットローテーションに関するトラブルシューティングのシークレットのローテーションを設定しようとすると「アクセス拒否」が発生するの部分に書かれていたコマンドを実行したら一発で解決した。

aws lambda add-permission --function-name ARN_of_lambda_function --principal secretsmanager.amazonaws.com --action lambda:InvokeFunction --statement-id SecretsManagerAccess

アクセストークンを更新するために実装したLambda

import json
import requests
from datetime import datetime, timezone, timedelta
from SecretManager import SecretManager

def lambda_handler(event, context):
    sm = SecretManager('Fitbit')
    secret = sm.get()
    headers = {
        'Authorization':'Basic '+secret['BASIC_TOKEN'],
        'Content-Type':'application/x-www-form-urlencoded'
    }
    data = {
        'grant_type':'refresh_token',
        'refresh_token':secret['REFRESH_TOKEN']
    }
    response = requests.post(
        'https://api.fitbit.com/oauth2/token', 
        headers = headers, 
        data = data
    )
    params = json.loads(response.content)
    secrets = {
        'BASIC_TOKEN': secret['BASIC_TOKEN'],
        'CLIENT_ID': secret['CLIENT_ID'],
        'CLIENT_SECRET': secret['CLIENT_SECRET'],
        'ACCESS_TOKEN': params['access_token'],
        'REFRESH_TOKEN': params['refresh_token'],
        'UPDATED': datetime.now(timezone(timedelta(hours=9))).strftime('%Y/%m/%d %H:%M:%S'),
    }
    sm.update(secrets)
    
    return {
        'statusCode': 200,
        'body': 'update!!'
    }

Lambdaでやっていることは、大きく分けて2つ。

  1. SecretManagerに保存されているトークンの操作(取得と更新)
  2. fitbit APIの認証エンドポイントにリフレッシュトークンをpostして、新たなアクセストークン、リフレッシュトークンを取得する

SecretManager classは、SecretManagerのサンプルコードをもとに処理をclassにまとめただけのもので別に特筆するものではないが一応コードを残しておこうと思う。boto3というAWS操作のためのPythonライブラリを使用している。

from botocore.exceptions import ClientError
import base64
import boto3
import json

class SecretManager:
    
    def __init__(self, secret_name, region_name = 'ap-northeast-1'):
        self.region_name = region_name
        self.secret_name = secret_name
        self.client = self.get_client()

    # secret manager client
    def get_client(self):
        # Create a Secrets Manager client
        session = boto3.session.Session()
        client = session.client(
            service_name = 'secretsmanager',
            region_name = self.region_name
        )
        return client
    
    # secret manager トークン更新
    def update(self, update_json):
        try:
            return self.client.update_secret(
                SecretId = self.secret_name, 
                SecretString = json.dumps(update_json)
            )
        except ClientError as e:
            print(e)
    
    # Secrets Managerのサンプルコードを参考に
    def get(self):
        try:
            get_secret_value_response = self.client.get_secret_value(
                SecretId=self.secret_name
            )   
        except ClientError as e:
            print(e)
            if e.response['Error']['Code'] == 'DecryptionFailureException':
                # Secrets Manager can't decrypt the protected secret text using the provided KMS key.
                # Deal with the exception here, and/or rethrow at your discretion.
                raise e
            elif e.response['Error']['Code'] == 'InternalServiceErrorException':
                # An error occurred on the server side.
                # Deal with the exception here, and/or rethrow at your discretion.
                raise e
            elif e.response['Error']['Code'] == 'InvalidParameterException':
                # You provided an invalid value for a parameter.
                # Deal with the exception here, and/or rethrow at your discretion.
                raise e
            elif e.response['Error']['Code'] == 'InvalidRequestException':
                # You provided a parameter value that is not valid for the current state of the resource.
                # Deal with the exception here, and/or rethrow at your discretion.
                raise e
            elif e.response['Error']['Code'] == 'ResourceNotFoundException':
                # We can't find the resource that you asked for.
                # Deal with the exception here, and/or rethrow at your discretion.
                raise e
        else:
            # Decrypts secret using the associated KMS CMK.
            # Depending on whether the secret is a string or binary, one of these fields will be populated.
            if 'SecretString' in get_secret_value_response:
                secret = get_secret_value_response['SecretString']
                return json.loads(secret)
            decoded_binary_secret = base64.b64decode(get_secret_value_response['SecretBinary'])
            return json.loads(decoded_binary_secret)

実際にTweetするのに実装したLambda

import json
from Twitter import Twitter
from Fitbit import Fitbit

def lambda_handler(event, context):

    results = {}
    fitbit = Fitbit()
    for resource in fitbit.get_resources():
        data = fitbit.intraday_time_series(resource)
        print(data)
        results[list(data.keys())[0]] = list(data.values())[0][0]['value']
    results['datetime'] = list(data.values())[0][0]['dateTime']
    
    distances = float(results['activities-tracker-distance']) * 1.61

    message =  f"本日({results['datetime']})の運動 from Fitbit\n\n"
    message += f"座っていた時間: {results['activities-tracker-minutesSedentary']}分\n"
    message += f"軽い運動の時間: {results['activities-tracker-minutesLightlyActive']}分\n"
    message += f"アクティブな運動の時間: {results['activities-tracker-minutesFairlyActive']}分\n"
    message += f"激しい運動の時間: {results['activities-tracker-minutesVeryActive']}分\n\n"
    message += f"本日の歩数: {results['activities-tracker-steps']}歩 ({distances:.3f}km)\n\n"
    message += f"消費カロリー: {results['activities-tracker-calories']}kcal ("
    message += f"基礎代謝: {results['activities-caloriesBMR']}kcal)"
    print(message)
    
    sleep = fitbit.sleep()['summary']
    sleep_message = f"本日({results['datetime']})の睡眠時間 from Fitbit\n\n"
    sleep_message += f"睡眠時間: {sleep['totalMinutesAsleep']/60 :.3f}時間"
    print(sleep_message)

    # twitterへメッセージを投稿する
    twitter = Twitter()
    twitter.status_update(message)
    twitter.status_update(sleep_message)
    
    return {
        'statusCode': 200,
        'body': json.dumps(results)
    }

今回Fitbit APIで取得する対象は、以下のものであり、これらのエンドポイントから情報を取得する部分は、fitbitのPythonライブラリを用いている。 Activity & Exercise Logsに定義されているresourceを

intraday_time_series(resource, base_date='today', detail_level='1min', start_time=None, end_time=None)

に指定してあげると、その情報を1日のサマリーを取得してくれる。

ただし、これが実行されるのは日付が変わってからなので、実際には以下のように前日の日付で取得している。

    # 前日のサマリーを取得したいので。
    def intraday_time_series(self, resource):
        yesterday = datetime.now(timezone(timedelta(hours=9))) - timedelta(days=1)
        return self.client.intraday_time_series(resource, base_date = yesterday)
    # 取得対象
    RESOURCES = [
        'activities/caloriesBMR',
        'activities/tracker/calories',
        'activities/tracker/steps',
        'activities/tracker/distance',
        'activities/tracker/minutesSedentary',
        'activities/tracker/minutesLightlyActive',
        'activities/tracker/minutesFairlyActive',
        'activities/tracker/minutesVeryActive',
        'activities/tracker/activityCalories',
    ]

cloud watchに吐き出されている実際のログ。加工が必要なのは移動距離くらいでそれ以外はそのまま出力しても問題ない値になっている。

{'activities-caloriesBMR': [{'dateTime': '2021-05-23', 'value': '1560'}]}
{'activities-tracker-calories': [{'dateTime': '2021-05-23', 'value': '1988'}]}
{'activities-tracker-steps': [{'dateTime': '2021-05-23', 'value': '1781'}]}
{'activities-tracker-distance': [{'dateTime': '2021-05-23', 'value': '0.75363626392912'}]}
{'activities-tracker-minutesSedentary': [{'dateTime': '2021-05-23', 'value': '551'}]}
{'activities-tracker-minutesLightlyActive': [{'dateTime': '2021-05-23', 'value': '90'}]}
{'activities-tracker-minutesFairlyActive': [{'dateTime': '2021-05-23', 'value': '4'}]}
{'activities-tracker-minutesVeryActive': [{'dateTime': '2021-05-23', 'value': '7'}]}
{'activities-tracker-activityCalories': [{'dateTime': '2021-05-23', 'value': '441'}]}

上述したデータから生成された実際のメッセージ。

本日(2021-05-23)の運動 from Fitbit
座っていた時間: 551分
軽い運動の時間: 90分
アクティブな運動の時間: 4分
激しい運動の時間: 7分
本日の歩数: 1781歩 (1.213km)
消費カロリー: 1988kcal (基礎代謝: 1560kcal)

睡眠情報に関しては、sleep()メソッドを呼び出している。

本日(2021-05-23)の睡眠時間 from Fitbit
睡眠時間: 12.433時間

あとはこのLambdaをEvent Bridge(Cloud watch Events)で、アクセストークンの更新が終わったであろう、00:20に呼び出してあげれば晴れて以下のようなツイートがされるというわけである。

ここまでのコードは、ここにすべてまとめてあります。

まとめ

Fitbit + Lambda + SecretManager + Twitter で、健康状況を常に意識する環境を整えた。 (この報告を深夜2時に書いている時点で、睡眠不足は必至なのだが…)

今後は、この値の平均値などが一定の閾値を下回ったり、上回ったりした時にも通知が飛ばせるようになれば、アラートとしても使えるような気がする。SREの勉強会で読んだ、入門監視を再度読み直して、生かしてみるのもいいのかもしれない。


Written by Blackcat ひよっこエンジニア, いつかは自分でサービスを作りたいとずっと言ってる Twitter