💓

Fitbitデバイスで自分(物理)の死活監視APIを立てる

2021/07/21に公開

みなさ~~~~ん、生きてますか?

インターネットを通じてバーチャルでの交流も当たり前となった今の時代ですが、リアルの知り合いとは異なり死んだ場合に知らせる手段が実質ないですよね。
たまたま家族がSNSにログインできたり、他の知り合いを通じて公表できたりというパターンもありますが、最近になっていろいろな人の訃報を耳にするたび、人間いつ死ぬか分かったものではないと思い自分自身の死活監視に使えるAPIを作ることにしました。

勉強の一環として開かれている社内ライブコーディング会でFitbit Web APIのクライアントを作ったので、これをもとに外部から参照できるAPIを作ります。

方針

Fitbitについて

いわゆるスマートウォッチの一つであるFitbitは、心拍数をはじめとするアクティビティを記録することができます。

https://fitbit.com

Fitbitに同期されたデータはWeb APIを通して読み出せることができるので、直近にデータが同期していれば生きているだろうという形で表現することとしました。

判定方法について

自分以外にも同様の試みをされている方はいますが、心拍数が0になるという判定方法については疑問を感じています。
Fitbitデバイスの場合はまず公式で測定できる心拍数が40-220bpmの範囲とされているので、そもそも0という値はとらないと考えられます。

またFitbitデバイスに使われているような光学式心拍センサーでは、血液量の変化による毛細血管の収縮、拡張を光の吸収具合から測定しています。
心臓が機能しなくなるなどして脈拍が消失した場合ですが、この光吸収の変化がなくなるためにデバイスが身につけられていないと判断され、データは記録されず同期もされないのではないかと考えています。
加えて災害に巻き込まれた際の救助の目安として72時間という幅があることから、これを最終的な判定基準とすることにしました。

実装

全体の実装は以下のgistから参照できます。

https://gist.github.com/paralleltree/cfd86f8c9b54ea3664315d465bd3d95a

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は以下より参照できますので、更新が途絶えたらその時がやって来たと思ってください。
https://cherry.paltee.net/heartbeat/latest.json

Discussion