我的主页有心跳
如今,我的个人主页上多了一颗小小的红色心形图案,它正在跳动。旁边显示的数字正是我实际的心率——心脏以这样的频率搏动:每1.25秒跳动一次,心率是48次/分钟;每0.75秒跳动一次,心率则是80次/分钟。
我之所以写下这段文字,只是因为我自己想这么做,也因为最终的设计中,包含了比我预想中更加丰富、有趣的决策与选择。我佩戴的这款手表是佳明Descent Mk3i,表壳直径43毫米,采用碳纤维灰色DLC涂层钛金属打造。
我把它当作主要的潜水电脑来使用,与我的Shearwater Perdix 2并肩作战——因为在洞穴和沉船等技术潜水及高海拔环境里,冗余设计至关重要。
如果在洞穴深处75米处,某台电脑突然失灵,你可不能直接浮出水面去给它充电。洞穴潜水员会为自己的备份做备份。自2025年4月以来,它已经和我一起完成了400多次下潜,其中最深的下潜深度达到了100米。
除非我需要给它充电或洗澡,否则我从不摘下它。在每次下潜之间,它会全天候追踪我的心率、睡眠质量、HRV、步数、卡路里消耗,以及其他十余项生命体征,并将所有数据同步至我手中的Pixel 9 Pro上的佳明Connect应用程序。
我的健身房训练和街头举重课程也会被记录在这款手表上。换句话说,佳明早已为我的一生记录下了连续而细致的生理数据。我只是需要把这些数据从佳明导出,并上传到我的个人主页上。
一个更贴近个人生活的“在线”状态指示器——过去那些消息应用都用绿色圆点来表示。比如Facebook Messenger,以及在那之前使用的Gchat:一个小小的指示器,用来表明此刻对方就在这里。
我记得自己在2012年第一次在HexChat上看到这个功能,那时我还未迁移到Irssi。我原本希望在本网站上也能拥有这样的功能,只是那个绿色圆点总觉得更像是对某个“插座”的一种陈述,而非对一个人的真正表达。
而心跳,正是最不抽象的“在线”状态之一。它代表着“他的浏览器已建立连接”与“他依然活着,而且这就是证明”。归根结底,这不过是一串页面上的数字,这一点我完全理解。
但这是属于我的数字,来自我的胸膛,也因此成为网站上最为私密、最能触动人心的部分。也许对你们中的很多人来说,这听起来有点尴尬,但我就是很喜欢这个想法 :P。
长远来看,等我有时间的时候,我希望能在这个网站上开辟一个版块,将我的人生细粒度的记录公之于众:训练计划、睡眠状况、HRV、消耗的卡路里、走过的步数等等。
我也明白,这些数据可能会泄露一些信息。全天候的心率可以揭示我在睡眠和醒来时的状态;心率的波动和时区的差异则能反映出我的旅行轨迹;而静息心率突然跃升8次/分钟,则可能提示我身体出现了健康问题、压力过大,或是昨晚喝过酒。
HRV的变化趋势,恰好可以作为衡量心理状态的可靠指标;而规律的健身计划,则能精准地告诉我,我何时不在家。我曾仔细思考过这一切,并最终决定:我对此感到满意。
我完全同意这种做法,如果你对此有所顾虑,那也是你的事,不是我的事 ¯\ (ツ) /¯。肯定有人早就做过这样的事情了。没错,他们确实这么做了。
我最初的想法是:这种功能一定早就存在了,而事实也的确如此。多年来,Twitch主播们一直通过Pulsoid和HypeRate等服务,在屏幕上实时显示心率,而这两款应用都支持佳明手表。
它们的工作原理是:将穿戴设备切换至广播模式下的心率监测,通过BLE/ANT+无线传输,将心率数据发送至手机App,由该App将数据转发至服务器。
起初,这似乎很有前景,但对我而言却走向了死胡同。佳明的用户手册提醒我们,广播模式会缩短电池续航时间,而这款手表本就是我的潜水电脑。
我绝不会为了一个网站小部件,而牺牲潜水日所需的电池电量。此外,广播模式是一种需要手动开启、且始终处于信号范围内的模式。对我来说,这似乎太麻烦了。
而且,我喜欢的是:我每周只需给手表充电一次,就能持续使用10天。我可不想每晚都给它充电,只为让一个小部件维持运行。浏览器本身也可以通过蓝牙进行通信,这在一开始还显得颇具潜力。
Web Bluetooth API可以直接从附近的设备读取标准的心率GATT服务。然而,这仍然行不通——因为这种方式只会将访客的浏览器与访客附近的设备相连。
它只能在我的自家机器上运行,仅限于Chromium浏览器,且手表必须处于广播模式。当然,总会有真正的API存在的。的确有:佳明的Health API能够提供这些数据,但其访问权限受限于商业应用和审核流程。
这个API专为那些将可穿戴设备集成到产品中的企业而设计。那么,对于一位想要在个人主页上展示心率的用户来说呢?哎呀……(╯’□’)╯︵ ┻━┻。
这时,我做了本该首先做的事——在GitHub和Reddit上搜索。在Reddit的讨论帖子里,有两个项目不断浮现出来。GarminDB会将你的整个佳明历史下载到本地的SQLite数据库中,并为你提供详尽的分析与图表。
听起来很酷,但并不适合我。一个带有Schema的存档,似乎有些过于复杂……我大概只想每天早上以JSON格式获取一份心率数据。python-garminconnect正是合适的工具——它是一个Python客户端,使用与佳明Connect App相同的私有API。
它可以像App一样完成登录操作,并调用与App完全一致的端点。它几乎完整地暴露了我在手机上能看到的一切功能,顺便说一句,这也解决了未来某个项目中的难题:无论是训练、睡眠,还是HRV,都可以通过同一个客户端轻松访问。
太棒了!感谢@cyberjunky!为什么不直播呢?其实,直播从来就不是我们的选项,但一开始我并不知道这一点。默认情况下,这款手表并不会进行任何直播:全天候的心率数据会累积在手腕上,每当手表与手机完成同步时,便会分批传输至佳明的服务器。
如果没有广播模式,就无法订阅任何直播内容。而我的网站是静态的——Astro只构建了一次,然后通过GitHub Pages以文件形式提供服务。
这里没有服务器可以保持WebSocket打开,因此即使手表真的进行了直播,我也找不到办法将其传递给访客。唯一的选择,就是每天抓取一次数据并重新播放。
于是,折衷方案就是:小部件会回放昨天的数据。当你在14:32(按照我的时间)加载我的主页时,你会看到我昨天14:32的心率,它是根据真实样本插值得出的。
“直播”这个标签其实只是个善意的谎言,但毕竟这是属于我的说法。不过至少,这个谎言是精确的:偏移量正好是24小时,数据真实无误,除了在各组测量值之间绘制直线之外,没有任何虚构成分。
一次性的登录(为期一年)——python-garminconnect会使用电子邮件和密码登录,但它返回的却是两组OAuth令牌。OAuth1令牌是长期有效的,大约能使用一年。
而OAuth2令牌才是真正授权API调用的令牌,它的有效期只有几个小时。该库会用OAuth2令牌对每个请求进行签名,每当OAuth2令牌到期时,它会自动使用OAuth1令牌,在后台生成一个新的令牌(我对此的理解有限)。
所以,我的凭据只需在笔记本电脑上输入一次,之后便再也不会靠近CI了。CI只保存那些能自行刷新一年的令牌。一次性登录的部分如下所示: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代码: ") garmin.resume_login(client_state, mfa) garmin.client.dump(str(tokendir)) # 令牌文件 -> ~/.garminconnect b64_path.write_text(garmin.client.dumps()) # 同样的令牌,只是一串Base64字符串 return_on_mfa=True 让库在佳明要求输入双重验证码时暂停并交还控制权,而不是直接退出,而resume_login则会在我输入的内容基础上完成握手过程。
最后,这些令牌会以两种形式出现:一种是作为文件存储在~/.garminconnect中,供本地运行使用;另一种则是一串Base64二进制数据(client.dumps()会将整个令牌库序列化为一个字符串),随后这一字符串会被用作GitHub Actions的密钥:gh secret set GARMINTOKENS_BASE64 --repo snehankekre/snehankekre.github.io < ~/.garminconnect.b64 我在登录时遇到了一些问题。
佳明在尝试登录的过程中限制了我的IP地址,尽管我已经频繁更换了Mullvad的服务器。该库尝试了多种登录策略,前两个(移动端+CFFI,以及移动端+Requests)都遭遇了429错误(“佳明限制了IP访问速率”)。
所幸,该库在链路的下游提供了备用方案……