Fitbitデバイスで自分(物理)の死活監視APIを立てる
みなさ~~~~ん、生きてますか?
インターネットを通じてバーチャルでの交流も当たり前となった今の時代ですが、リアルの知り合いとは異なり死んだ場合に知らせる手段が実質ないですよね。
たまたま家族がSNSにログインできたり、他の知り合いを通じて公表できたりというパターンもありますが、最近になっていろいろな人の訃報を耳にするたび、人間いつ死ぬか分かったものではないと思い自分自身の死活監視に使えるAPIを作ることにしました。
勉強の一環として開かれている社内ライブコーディング会でFitbit Web APIのクライアントを作ったので、これをもとに外部から参照できるAPIを作ります。
方針
Fitbitについて
いわゆるスマートウォッチの一つであるFitbitは、心拍数をはじめとするアクティビティを記録することができます。
Fitbitに同期されたデータはWeb APIを通して読み出せることができるので、直近にデータが同期していれば生きているだろうという形で表現することとしました。
判定方法について
自分以外にも同様の試みをされている方はいますが、心拍数が0になるという判定方法については疑問を感じています。
Fitbitデバイスの場合はまず公式で測定できる心拍数が40-220bpmの範囲とされているので、そもそも0という値はとらないと考えられます。
またFitbitデバイスに使われているような光学式心拍センサーでは、血液量の変化による毛細血管の収縮、拡張を光の吸収具合から測定しています。
心臓が機能しなくなるなどして脈拍が消失した場合ですが、この光吸収の変化がなくなるためにデバイスが身につけられていないと判断され、データは記録されず同期もされないのではないかと考えています。
加えて災害に巻き込まれた際の救助の目安として72時間という幅があることから、これを最終的な判定基準とすることにしました。
実装
全体の実装は以下のgistから参照できます。
APIキーを払い出す
まずはFitbitの開発者向けサイト(https://dev.fitbit.com)から連携用のアプリケーションを作成します。
新規登録画面から必須の項目を埋めていきますが、心拍数を取得するためにOAuth 2.0 Application Type
をPersonalとし、Redirect URL
はサービスが立ち上がっていない適当なもの(http://localhost:8888
など)を指定します。
また、権限は絞っておきたいのでDefault Access Type
はRead Onlyにしておきます。
アプリケーションを作成したらClient IDとClient Secretを控えておきます。
OAuthクライアントを作成する
今回はRubyでoauth2 gemを使ったクライアントを作成していきます。
OAuth 2.0でのアクセストークンとリフレッシュトークンを保存するクラスとして雑にTokenStoreクラスを作ります。
ここではシンプルに認証情報をファイルに書き込んだり読み出したりします。
class TokenStore
def initialize(cache_file_path)
@file_path = cache_file_path
end
def save(token)
open(@file_path, 'w') { |f| f.write(JSON.generate({ access_token: token.token, refresh_token: token.refresh_token })) }
end
def restore
raise unless available?
credentials = open(@file_path) { |f| JSON.parse(f.read) }
yield [credentials['access_token'], credentials['refresh_token']]
end
def available?
File.exists?(@file_path)
end
end
続いてFitbit用にOAuthの部分をラップしたクラスを作成することとしました。
認証後はgetメソッドを通じてAPIにアクセスしますが、トークンの有効期限が切れた際にリフレッシュして保存、リトライする処理を加えています。
class FitbitClient
class InvalidAccessTokenError < StandardError; end
AUTHORIZATION_URL = 'https://www.fitbit.com/oauth2/authorize'
ACCESS_TOKEN_REQUEST_URL = 'https://api.fitbit.com/oauth2/token'
def initialize(client_id, client_secret, redirect_uri, token_store)
@redirect_uri = redirect_uri
@token_store = token_store
@client = OAuth2::Client.new(client_id, client_secret, site: ACCESS_TOKEN_REQUEST_URL.match(%r{[^/]*//[^/]*}).to_s, authorize_url: AUTHORIZATION_URL, token_url: ACCESS_TOKEN_REQUEST_URL)
if token_store.available?
@token = token_store.restore do |access_token, refresh_token|
OAuth2::AccessToken.new(@client, access_token, refresh_token: refresh_token)
end
end
end
def authorize_url(redirect_uri)
@client.auth_code.authorize_url(redirect_uri: @redirect_uri, scope: 'heartrate')
end
def authorize(code)
obtain_token { @client.auth_code.get_token(code, redirect_uri: @redirect_uri, client_id: @client.id, headers: authorization_header) }
end
def get(*args)
retrying = false
begin
@token.get(*args)
rescue OAuth2::Error => ex
raise if retrying
res = JSON.parse(ex.response.body)
errorTypes = res['errors'].map { |e| e['errorType'] }
if errorTypes.any?('expired_token')
obtain_token { @token.refresh!(headers: authorization_header) }
retrying = true
retry
end
raise InvalidAccessTokenError if errorTypes.any?('invalid_token') || errorTypes.any?('expired_token')
end
end
private
def obtain_token
@token = yield
@token_store.save(@token)
end
def authorization_header
{ 'Authorization': 'Basic ' + Base64.encode64("#{@client.id}:#{@client.secret}").chomp! }
end
end
アクセストークンを取得する
以上のクラスを用いてアクセストークンを取得します。
FitbitClient#authorize_url
を叩いて認証用のURLを開くと、以下のようにリダイレクト先のURLに認証コードがくっついた状態になります。
http://localhost:8888/callback?code=7b64c4b088b9c841d15bcac15d4aa7433d35af3e#_=_
このうち7b64c4b088b9c841d15bcac15d4aa7433d35af3e
をコードとしてFitbitClient#authorize
に渡すと、認証とトークンの保存が完了します。
以下はここまでの処理を行うスクリプトになります。
CLIENT_ID = 'xxxx'
CLIENT_SECRET = 'xxxx'
REDIRECT_URL = 'http://localhost:8888'
CREDENTIAL_CACHE = 'credentials.json'
token_store = TokenStore.new(CREDENTIAL_CACHE)
client = FitbitClient.new(CLIENT_ID, CLIENT_SECRET, REDIRECT_URL, token_store)
puts client.authorize_url(REDIRECT_URL)
print "Enter the authorization code: "
code = STDIN.gets.chomp!
client.authorize(code)
FitbitでのAuthorizationとToken Requestのエンドポイントのドメインはなんで分かれているんでしょうね?
ちゃんとドキュメントを読んでいなかったので10分ぐらい404エラーとにらめっこしてしまいました。
心拍数を取得する
次に最新の心拍数レコードを取得するメソッドを以下のように作成します。
今日の日付から7日間に遡ってAPIを叩き、レコードが見つかった時点で時刻と心拍数を返しています。
def get_latest_heartbeat(client)
date = Date.today
since = date.prev_day(7)
while date > since
data = JSON.parse(client.get("/1/user/-/activities/heart/date/#{date.strftime("%Y-%m-%d")}/1d/1min.json").body)
latest = data['activities-heart-intraday']['dataset'].last
if latest
time = latest['time'].scan(/\d+/).reverse.map.with_index { |d, i| d.to_i * (60 ** i) }.sum
return { time: date.to_time + time, value: latest['value'] }
end
date = date.prev_day
end
return nil
end
最新の心拍数データの時刻と現在時刻の差をとり、以下のように標準出力に出力します。
latest = get_latest_heartbeat(client) || {}
behind_in_sec = Time.now - (latest[:time] || Time.at(0))
output = {
last_sync_time: latest[:time]&.iso8601,
heart_rate: latest[:value],
status: behind_in_sec < 60 * 60 * 72 ? "alive" : "dead"
}
puts JSON.pretty_generate(output)
cronで定期実行する
自分の場合はさくらのVPSを契約しているのですが、ここまでの内容をclient.rb
として保存し、crontabに登録して定期実行します。
*/10 * * * * bash -c 'cd fitbit && out=$(ruby client.rb) && echo "$out" > public/latest.json'
10分に一度実行し、正常に終了した場合にのみファイルに書き込みます。
nginxを通して公開する
最後にnginxを通じて書き込んだファイルを公開します。
前節で一段public
ディレクトリを掘って保存していたのは認証情報が記録されているディレクトリを公開するとまずいためです。
以下のようにしてパスを切り、latest.json
を配信する設定を反映して完了です。
server {
listen 443 ssl http2;
server_name cherry.paltee.net;
server_tokens off;
## TLSの設定とかもろもろ
location /heartbeat {
alias /home/paltee/fitbit/public;
}
}
おわりに
死って怖いですよね。
人間である以上逃れられない部分ではありますが、この世界に干渉できなくなってしまうと考えると嫌だなあという気持ちになります。
作成したAPIは以下より参照できますので、更新が途絶えたらその時がやって来たと思ってください。
Discussion