デバイスを着けていたかは API から取れない。心拍で代用する — Google Health API で週次ダッシュボードを作った記録

1. 何を作ったか

Fitbit Charge 6 で記録した自分のデータを Google Health API から取得して、週次の振り返り用ダッシュボードを HTML で自動生成する仕組みを作りました。

  • 1日1本・0:00〜24:00 の帯グラフを7日分、縦に並べる
  • 1本のトラックに睡眠(ステージ別)・運動セッション・座りっぱなしを重ねる
  • 背景に歩数の濃淡を敷いて、活動の山がいつ来たかを見る
  • 毎週月曜の朝に launchd が走り、生成された HTML がブラウザで開く

外部ライブラリは使っていません。Python の標準ライブラリだけで、取得・正規化・描画の3段構成です。出力は CSS と JS をインラインした単一 HTML ファイル。

作成したダッシュボード。睡眠・運動などその日の活動を並べて確認できる。

ソースは全文公開しています → https://github.com/ebi-ebi-dev/google_health

ただ、この記事の主役はダッシュボードではなく、Google Health API を実際に叩いて、返ってきたエラーから仕様を1つずつ確定させていった過程のほうです。

日本語の情報がほとんど無い領域で、自分も最初の1時間はひたすら 400 を眺めていました。同じところで詰まった人が、フィルタ文字列とエラーコードの対応表をコピペして先に進めるように書きます。

前提:調査日は 2026年8月29日、対象は Fitbit Charge 6 と readonly スコープ8種です。API の仕様は変わりえます。以下は「公式ドキュメントに書かれていない」と断定するものではなく、自分が探した範囲では見つけられず、実測で確認したという話として読んでください。

2. なぜ自作したか

公式アプリ(Google Health)は「その日」を見るのがとても得意です。昨夜の睡眠ステージ、今日の歩数、直近の心拍。推移で見れてもそれは睡眠や運動など個別にしか見られません。

Google Healthのアプリ画面。睡眠や運動それぞれの推移しか見られない。

ただ、自分が知りたかったのはそうではありませんでした。

「運動した日は、その夜の就寝時刻が早いのか」。この手の問いは、同じ時間軸の上に別々の指標を並べて、7日分まとめて眺めないと見えてきません。睡眠タブと運動タブを往復していると、比較する前に記憶のほうが薄れてしまいます。

軸を選ぶのも、並べる順を決めるのも、本来は見る側の自由でいいはずです。データは自分のものなので、取ってきて自分で描けばいい、というだけの話でした。実際に作ってみたら、API そのものの調査のほうが本編になってしまったのですが。

3. 構成

3段に分けています。

scripts/fetch.py       API → data/YYYY-Www/*.json.gz   ※要ネットワーク
scripts/normalize.py   生JSON → normalized.json.gz     ※ネットワーク不要
scripts/render.py      normalized.json.gz → 単一HTML   ※ネットワーク不要
scripts/weekly.sh      上の3段を一括実行

ポイントは ネットワークを必要とする段が fetch.py だけという点です。この分離が後でとても効きました。

API から返ってきた生レスポンスは加工せず gzip でそのまま保存しています。仕様の理解が後から変わっても取り直しが要りませんし、実測で1週間 2.9MB が gzip で 78KB、年間でも 4MB 程度なので、残さない理由がありません。

正規化以降は API に触れないので、描画の試行錯誤は何度でもやり直せます。ガントバーの高さを変えるたびに OAuth を通す、というのは無駄が多いです。

正規化後の中間形式は「1レコード = 1セグメント」(date, kind, stage, start_at, end_at, source)にしてあります。将来 DB に移すときにテーブルへそのまま写せる粒度を意識しました。CSV が欲しくなっても、ここから落とせます。

描画はチャートライブラリを使わず、CSS の絶対配置 div で済ませました。1日24時間を 100% とみなして left: x%; width: y% を置くだけなので、依存ゼロで十分に読める図になります。

4. ハマりどころ

ここからが本題です。最初のスクリプトを走らせたとき、取得対象のほぼ全部が 400 か 403 で落ちました。そこからの切り分けの記録になります。

① まず、エラーの種類が2つあることに気づく

返ってきたメッセージをよく見ると、400 の中身が2種類ありました。

The data type ID is not supported
INVALID_DATA_POINT_FILTER_DATA_TYPE_RESTRICTION

前者は「データ型そのものがこのエンドポイントに対応していない」、後者は「フィルタの書き方が違う」。同じ 400 でも原因が別だと分かった時点で、調査が2本に分かれました。

② データ型ごとに対応エンドポイントが違う

The data type ID is not supported が返る型は、そもそも GET .../dataPoints(= list)を持っていませんでした。全データ型が list を持っているわけではない、というのが今回の最大の発見です。

自分が踏んだのは次の3つ。

データ型対応しているエンドポイント
floorsreconcile / rollUp / dailyRollUp
total-caloriesrollUp / dailyRollUp
calories-in-heart-rate-zonerollUp / dailyRollUp

いずれも「集計してこそ意味がある」型で、生の時系列は提供しない設計に見えます。rollUpwindowSizeSeconds 指定)か dailyRollUp を使えば取れます。total-calories にはさらに 時間区間フィルタが必須で、指定できるのは最大14日 という制約もついていました。

紛らわしいのは、time-in-heart-rate-zone は list に対応している点です。心拍ゾーンでも「時間」は list でき、「カロリー」はできません。名前が似ているので、片方が通ったからもう片方も通るだろうと考えると、また 400 を踏みます。

③ filter の接頭辞は snake_case

もう一方の 400、INVALID_DATA_POINT_FILTER_DATA_TYPE_RESTRICTION のほうが厄介でした。

原因は、レスポンスの JSON キーを見ながらフィルタを書いたことでした。レスポンスは camelCase(activeMinutes)で返りますが、フィルタでは通りません。データ型 ID のハイフンをアンダースコアに置き換えた snake_case で書きます。

実際に通った組み合わせが以下です。ここが一番コピペしてほしいところ。

dataType使えるフィールド値の形式
sleepsleep.interval.end_timeRFC-3339
exerciseexercise.interval.civil_start_time"2026-08-17"(日付のみ)
stepssteps.interval.start_timeRFC-3339
active-minutesactive_minutes.interval.start_timeRFC-3339
sedentary-periodsedentary_period.interval.start_timeRFC-3339

クエリはこの形になります。

GET https://health.googleapis.com/v4/users/me/dataTypes/sleep/dataPoints
  ?pageSize=25
  &filter=sleep.interval.end_time >= "2026-08-24T00:00:00+09:00"
      AND sleep.interval.end_time < "2026-08-31T00:00:00+09:00"

ベース URL も、https://health.googleapis.com/v4https://www.googleapis.com/health/v4 の両方の記述を見かけて迷いましたが、前者で通りました

そして切り分けに一番効いたのが、エラーコードで原因が分かるという点です。

  • INVALID_DATA_POINT_FILTER_DATA_TYPE_MEMBERフィールド名が違う
  • INVALID_DATA_POINT_FILTER_DATA_TYPE_RESTRICTION接頭辞が違う

MEMBER が出たら接頭辞ではなくフィールドを疑えばいい、と分かってから一気に進みました。

実際、exerciseexercise.interval.start_time を渡すと MEMBER が返ります。セッション型は civil_start_time しか使えず、値も RFC-3339 ではなく日付文字列でした。sleep だけは例外で、こちらは開始ではなく 終了時刻でしか絞れません

最初は「なぜ end だけ」と思いましたが、これは都合がよかったです。23:30 に寝て 7:00 に起きた睡眠が、終了時刻で絞れば取得範囲の初日に正しく入るからです。

細かいところでは、sleepexercisepageSize の上限が 25 でした。他の型で 10000 が通ったので、同じ値を投げて怒られました。

④ 読み取り手段が存在しないデータがある

moods がどうしても取れませんでした。フィルタを全部外しても通りません。

対応エンドポイントを確認して納得しました。moods は create / update / batchDelete しか持っていません。 list も get も無く、つまり API 経由での読み取り手段がそもそも存在しないわけです。

スコープ側から見ても整合していました。スコープ一覧(全18種)には .mindfulness.writeonlyありますが .mindfulness.readonlyありません。にもかかわらず、GCP コンソールの選択肢には .mindfulness.readonly が表示されます。コンソールに出ているのに API が対応していない、という食い違いです。同じ状態が logged_symptomsreproductive_health にもありました。

結果として moods / symptoms / menstrual-period / ovulation-test の4つは、デバイスに記録があっても取得できません。 回避策は見つかりませんでした。コンソールでスコープが選べるぶん、「許可すれば取れるはず」と思い込みやすいところです。

⑤ 403 の原因は専用スコープだった

electrocardiogramirregular-rhythm-notification だけが 403 でした。この2つは health_metrics_and_measurements に含まれておらず、独立したスコープを持っています。

https://www.googleapis.com/auth/googlehealth.ecg.readonly

https://www.googleapis.com/auth/googlehealth.irn.readonly

GCP の「データアクセス」で2つを追加して再同意すれば通ります。エンドポイントは両方とも list のみです。400 は書き方の問題、403 はスコープの問題と分けてしまえば、ここは素直でした。

⑥ ハードウェア起因で空になるもの

最後に残ったのが「200 は返るのに中身が空」というグループです。API を疑い続けましたが、原因はデバイス側でした。

  • altitude / floors … Charge 5 以降、気圧高度計が非搭載です。原理的に取れません。floors が list 非対応なのと合わせて、この線は諦めました
  • vo2-max … Fitbit では心肺機能スコアに相当し、GPS を伴うランニングのデータが必要です。自分の exercise は WALKING しか無いので未算出でした

空レスポンスを見たら、まずそのデバイスがそのセンサーを積んでいるかを疑ったほうが早いです。

5. 設計上の判断

API が一通り通ってからは、データの扱いで判断が要る場面が続きました。

心拍データが1週間で 92MB

心拍は約5秒粒度で返ってきます。実測で 1週間 151,129件・92MB。生のまま保存すると年 4.8GB になり、個人のアーカイブとしては現実的ではありません。

そこで取得後に 5分バケットへ集約してから保存することにしました({date, bin, n, bpm_min, bpm_avg, bpm_max} の形)。同じ1週間が 108KB になります。

ただし、捨てる前に1つ手順を挟みました。この心拍は後述の「装着していたか」の判定に使うので、集約の前後で判定結果が完全に一致することを実データで確認してから採用しています。捨てても結論が変わらないことを確認してから捨てる。この順番を守るだけで、あとから「あのとき間引いたせいでは」と疑う時間が消えます。生が要るときは --keep-raw-heart-rate を付けます。

「デバイスを装着していたか」は API に存在しない

帯グラフを描いていて最初に困ったのが、空白の意味が2つあることでした。「その時間は寝ていなかった」のか「そもそもデバイスを着けていなかった」のか、図の上では区別がつきません。

装着状態を表すデータ型もフィールドも見つかりませんでした。データ型一覧(43種)にそれらしいものは無く、heart-ratesensorLocationWRIST など)はありますが、これはセンサーの取り付け位置であって、実際に着けていたかではありません。

そこで 心拍を代理信号にしました。心拍は装着中しか記録されないので、心拍が無い時間帯 ≒ 外していた時間帯 とみなせます。

  1. 1日を5分バケット288個に分割する
  2. バケット内に心拍が1件でもあれば「装着していた」
  3. 装着中でも数分の欠測はあるので、2バケット(10分)以下の穴は埋める

描画では未装着帯をハッチで敷き、睡眠・運動の帯より下のレイヤーに置きました。何も記録が無い時間帯だけハッチが見えるので、「本当に寝ていない」と「着けていなかった」が一目で区別できます。

あくまで推定なので、充電中や同期前も未装着に見えます。5分粒度なので、短時間の着脱も捉えられません。そこは割り切っています。

[スクショ挿入:未装着ハッチが出ている日の拡大。睡眠帯とハッチが重ならず、記録が無い時間だけ斜線が見える状態を見せたい]

歩数が二重計上になる

steps を素直に合計したら、体感より明らかに多い値になりました。platformFITBIT(Charge 6)と HEALTH_KIT(iPhone / Apple Health 由来)の両方が入っていて、単純合計は二重計上になります。

実データ1週間で 1421件中 191件が HealthKit 由来でした。既定では FITBIT のみを採用し、除外した件数を注記として HTML に出すようにしています。黙って捨てると、後から自分で見たときに数字を疑えなくなるからです。

日付をまたぐ睡眠をどう数えるか

23:30〜7:00 の睡眠は、どちらの日の睡眠なのか。ここは描画と集計で別のルールにしました

  • 描画:0:00 の境界でセグメントを分割し、両日の帯に描く(物理的な時刻どおり)
  • 集計:セッション単位のまま、起床した日に丸ごと帰属させる

「火曜の睡眠=月曜の夜に寝て火曜の朝に起きた分」のほうが、振り返りの体感に合います。見た目は分かれるが集計は分かれない。この2つを混同しないよう、normalize の段で明示的に分けてあります。

睡眠ステージに ASLEEP が混ざる

ステージは AWAKE / REM / LIGHT / DEEP だと思っていたら、ASLEEP 単独のレコードが混ざっていました。ステージ判定が行われなかった記録(Fitbit の classic ログ)で、短い昼寝で発生しやすいようです。

未知の値として落とすと睡眠が短く出てしまうので、ダッシュボードでは「ステージなし」として縞模様で描き分けています。

トークンの寿命

OAuth クライアントが Testing ステータスのままだと、refresh token が約7日で失効します。週次実行の周期とちょうど重なるので、「毎週動いたり動かなかったり」という一番たちの悪い挙動になりやすいところです。

公開ステータスを本番にすれば解決しますが、Health API のスコープは機微データ扱いで審査が要る可能性が高く、個人の趣味プロジェクトには重すぎます。

そこで invalid_grant を検出したら、その場で認可 URL を組み立てて標準エラーに出して終わる導線を組み込みました。終了コードも分けてあります(fetch 失敗=2)。月曜のログの末尾に URL が出ているので、開いて同意して credentials を差し替えれば復帰できます。直せないなら、せめて直し方をエラーに書いておく、という妥協です。

6. 運用と、当日を含めるかどうか

launchd で毎週月曜に weekly.sh を回しています。StartCalendarInterval は指定時刻に Mac が寝ていても復帰後に一度だけ走ってくれるので、実用上は十分でした(launchctl load は非推奨で Load failed: 5 を返すことがあるため、bootstrap / bootout を使います)。

範囲指定には --days N を用意しましたが、既定では当日を含めない設計にしています。

実際に踏んだからです。当日は同期の途中でデータが欠けることがあり、「寝返りでデバイスが外れた夜」が極端に短い睡眠として記録され、週の平均を大きく引き下げる例が出ました。値としては嘘ではないのですが、振り返りの材料としては邪魔になります。

./scripts/weekly.sh                            # 先週(月〜日)
./scripts/weekly.sh --days 10                  # 昨日から遡る10日分
./scripts/weekly.sh --days 1 --include-today   # 今日だけ

後から --include-today を opt-in で足しました。既定は安全側、必要なら明示的に踏み込む。 自動実行するものほど、既定値は「黙って間違った数字を出さない側」に倒しておきたいです。

7. 作ってみて

一番効いたのは、具体的な数値として”健康であるか”が分かることでした。

アプリでその日のスコアを見るだけでは、じゃあ早く寝よう、昼寝しすぎないようにしよう、散歩しようなど、ちょっとした意識改革にしかなりません。

このダッシュボードを見れば、それが何日続いたのか、座ってる時間がどれぐらいだ、といった統計値で捉えることができます。(そもそも座ってる時間なんてアプリだとみられないですしね。)

ソースは全部置いてあります。同じことをやろうとして 400 を眺めている人の役に立てば。
https://github.com/ebi-ebi-dev/google_health


タイトルとURLをコピーしました