その他
私のホームページに鼓動がある
My Homepage Has a Pulse (snehankekre.com)
要約
著者は、自身のホームページにリアルタイムの心拍数を表示する小さな赤いハートのアイコンを設置しました。この心拍数は、Garminのスマートウォッチから取得され、ホームページの更新時に表示されます。当初は単なる「オンライン」ステータス表示を求めていましたが、最終的には個人の生理的データを公開することへの同意と、そのパーソナルな性質に価値を見出しています。
全文翻訳
私のホームページに今、鼓動する小さな赤いハートがあります。その横の数字は私の実際の心拍数で、ハートはその頻度でパルスします:48 bpmは1.25秒ごとに1回、80 bpmは0.75秒ごとに1回です。これを書いたのは、やりたかったから、そしてデザインに予想以上に興味深い決定が含まれていたからです。
時計
私はGarmin Descent Mk3i、43mm、カーボングレーDLCチタンを着用しています。テクニカルダイビングや洞窟、沈没船のようなオーバーヘッド環境では冗長性が必要なため、Shearwater Perdix 2と並んで、プライマリダイブコンピューターとして購入しました。洞窟の75m地点でコンピューターが故障した場合、ただ浮上して充電することはできません。ケーブダイバーはバックアップのバックアップを持っています。2025年4月以来、100mまでの400回以上のダイブに同行しています。
充電中やシャワーを浴びている時以外は、外しません。ダイビングの合間には、心拍数、睡眠スコア、HRV、歩数、カロリー、その他数十のバイタルサインを24時間体制で追跡し、すべてをPixel 9 ProのGarmin Connectアプリに同期しています。ジムでのセッションやストリートリフティングも記録されます。つまり、Garminはすでに私の人生の連続的で詳細な生理学的記録を保持しているのです。私はただ、そのデータをGarminから取り出してホームページに表示する必要がありました。
よりパーソナルな「オンライン」ステータスインジケーター
古いメッセージングアプリには緑色のドットがありました。Facebook Messenger、その前のGchat:この人が今ここにいることを示す小さなインジケーターです。2012年にHexChatでこれを見たのを覚えています。Irssiに移行する前です。このサイトでもそれを求めていましたが、緑色のドットは常に、人ではなくソケットに関する声明のように感じられました。心臓の鼓動は、最も抽象度の低い「オンライン」です。「ブラウザの接続が開いている」と「彼は生きている、そしてここにその証拠がある」との違いです。結局のところ、それはページ上の単なる数字です、それはわかっています。しかし、それは私の数字であり、私の胸からのものであり、それがサイト上で最もパーソナルなものとなっています。多くの人にとっては少しキモいかもしれませんが、私はそのアイデアが好きです :P。
長期的な目標は、いつか手が付けば、私の人生の詳細な記録を公開するセクションをこのサイトに設けることです:ワークアウト、睡眠、HRV、消費カロリー、歩数など。それが何を漏らすかは理解しています。終日の心拍数は、私がいつ眠り、いつ起きるかを示します。ギャップやタイムゾーンのシフトは旅行を示唆します。安静時心拍数が8 bpm上昇することは、病気、ストレス、または昨夜の飲み物を示唆します。HRVのトレンドは精神状態の良い代理指標であり、定期的なジムのスケジュールは私が家にいない時間を正確に広告します。私はそれらすべてと向き合い、問題ないという結論に至りました。私はそれに同意します。もしあなたがそれに問題があるなら、それはあなたの問題であり、私の問題ではありません ¯\(ツ)/¯。
きっと誰かがこれをやったことがあるでしょう。はい、やっています。
私の最初の考えは、これはすでに存在するはずだということでした、そしてそれは正しかったです。Twitchストリーマーは何年も前から、PulsoidやHypeRateのようなサービスを通じてライブ心拍数を画面に表示しており、どちらもGarminウォッチをサポートしています。これらは、ウェアラブルをブロードキャスト心拍数モードに入れ、BLE/ANT+経由で電話アプリにストリーミングし、その番号をサーバーに中継することで機能します。最初は有望に見えましたが、私にとっては行き止まりでした。Garminのマニュアルでは、ブロードキャストはバッテリー寿命を縮めると警告しており、この時計は私のダイブコンピューターです。ウェブサイトのウィジェットのためにダイビングの日のバッテリーを交換するつもりはありません。そして、ブロードキャストはオンに切り替えて範囲内に留まるモードです。私にとっては面倒すぎるように思えます。それに、週に一度充電して10日間持続するのが気に入っています。ウィジェットを生き続けさせるためだけに毎晩充電したくはありません。
ブラウザ自体はBluetoothで通信できます。これは一時的に有望に見えました。Web Bluetooth APIは、近くのデバイスから標準の心拍数GATTサービスを直接読み取ることができます。しかし、これは訪問者のブラウザを訪問者の近くのデバイスに接続するため、まだうまくいきません。それは私のマシン上で、Chromiumブラウザで、ブロードキャストモードの時計でのみ機能するでしょう。
それなら、本当のAPIがあるはずです。あります:GarminのHealth APIはこのデータを提供しますが、アクセスはビジネスアプリケーションとレビューによって制限されています。これは、ウェアラブルを製品に統合する企業向けに構築されています。ホームページにハートを載せたい男性はどうすればいいのでしょうか? ファーッ (╯’□’)╯︵ ┻━┻。
この時点で、私は最初にやるべきだったことを行い、GitHubとRedditを検索しました。Redditのスレッドでは、2つのプロジェクトが繰り返し登場しました。GarminDBは、Garminの履歴全体をローカルSQLiteデータベースにダウンロードし、その上に分析とプロットを提供します。クールですが、これは私の目的には合いません。スキーマ付きのアーカイブはやりすぎのように思えます…おそらく、毎朝1日分の心拍数をJSONで取得したいだけです。python-garminconnectは、Garmin Connectアプリが使用するのと同じプライベートAPIと通信するPythonクライアントであるため、適切なツールです。アプリと同じようにログインでき、アプリと同じエンドポイントを呼び出します。これは、私が携帯電話で見ることができるほぼすべてを提供します。これは、ワークアウト、睡眠、HRVがすべて同じクライアントからアクセスできるため、将来のプロジェクトの問題も incidentally 解決します。素晴らしい、@cyberjunkyさん、ありがとうございます!
なぜライブではないのか?
ライブは実際には選択肢ではありませんでしたが、最初は知りませんでした。時計はデフォルトではどこにもストリーミングしません:終日の心拍数は手首に蓄積され、時計と電話の同期が発生するたびに、バッチでGarminのサーバーに到達します。ブロードキャストモードがないと、購読できるフィードはありません。そして私のサイトは静的で、Astroで一度ビルドされ、GitHub Pagesからファイルとして提供されます。WebSocketを開いたままにしておくサーバーはないので、たとえ時計がストリーミングしたとしても、訪問者に中継する方法を見つけられませんでした。唯一の選択肢は、1日に1回データを取得して再生することです。
そのため、妥協点として、ウィジェットは昨日を再生します。あなたの時間で14:32に私のホームページをロードすると、私の心拍数が昨日の14:32に、実際のサンプルから補間されたものを見ることができます。「ライブ」というラベルは白い嘘ですが、それは私が語るための嘘です。しかし、少なくともその嘘は正確です:オフセットは正確に24時間、データは本物であり、測定値の間に直線を描く以外に何も作り出されていません。
一度だけのログイン(1年間有効)
python-garminconnectはメールとパスワードでログインしますが、返されるのはOAuthトークンのペアです。OAuth1トークンは長期間有効で、約1年間有効です。OAuth2トークンは実際にAPI呼び出しを承認するもので、数時間後に期限切れになります。ライブラリは、すべてのリクエストをOAuth2トークンで署名し、それが期限切れになるたびに、バックグラウンドで新しいトークンを生成するためにOAuth1トークンを使用します(私の限定的な理解では)。したがって、私の認証情報はラップトップで一度だけ入力され、CIに決して渡されることはありません。CIは、自身を更新するトークンのみを1年間保持します。
一度だけの部分は次のようになります:
python3 -m venv .venv
source .venv/bin/activate.fish
pip3 install garminconnect
python3 scripts/garmin_login.py
garmin_login.pyは、認証情報を入力させ、トークンを処理する短いスクリプトです。興味深い部分:
garmin = Garmin(email=email, password=password, return_on_mfa=True)
status, client_state = garmin.login()
if status == "needs_mfa":
mfa = input("MFA code: ")
garmin.resume_login(client_state, mfa)
garmin.client.dump(str(tokendir)) # token files -> ~/.garminconnect
b64_path.write_text(garmin.client.dumps()) # same tokens, one base64 string
return_on_mfa=Trueは、Garminが2要素コードを要求したときにライブラリが停止し、制御を戻すようにし、resume_loginは私が入力したものでハンドシェイクを完了します。最後に、トークンは2つの形式で保存されます:ローカル実行用の~/.garminconnectのファイルとして、そしてGitHub Actionsのシークレットになる単一のbase64ブロブ(client.dumps()はトークンストア全体を1つの文字列にシリアライズします)として。
gh secret set GARMINTOKENS_BASE64 --repo snehankekre/snehankekre.github.io < ~/.garminconnect.b64
ログインで問題がありました。IPをローテーションしていても、Garminは試行中に私のIPをレート制限しました。ライブラリはログイン戦略のチェーンを試行し、最初の2つ(mobile+cffi、次にmobile+requests)はどちらも429(「GarminによるIPレート制限」)を返しました。幸いなことに、ライブラリのその後のフォールバック戦略は成功しました。いずれにしても、それは一度だけの代償でした:CI