手表记录了我很长时间的睡眠、心率、HRV 和跑步。这些数据在官方 App 里都能看,但只能看:曲线是画好的,分数是算好的。想换个口径重算一遍,或者把原始数据拿出来做点别的事,基本没有出口。
所以我自己写了一个:元环(Vitaloop),一个开源、本地优先的 Android 运动健康看板。Flutter 写界面,Rust 写数据核心,手表通过经典蓝牙直连,数据同步进手机里的 SQLite,准备度、睡眠、训练负荷全部在本地算。客户端之外,可选的同步服务端也开源了。
现在是 v0.1 预览版,客户端和服务端都是 AGPL-3.0。
一、先把边界划清
它不是一个云端健康产品,也不是通用表盘 / 通知同步工具。定位就一件事:把华为 / 荣耀手表的数据落到本地,再用自己的规则算一遍。
- 不登录就能用全部本地功能:首页总览、睡眠详情、运动记录、数据统计、准备度
- 没有默认云服务、没有广告 SDK、没有产品遥测
- 只在用户填了自己的服务器地址之后,账号、云同步、实时共享才会出现
- 公开源码和公开构建里不包含私有域名、星历服务、API Key 或签名证书
亮色主题的四个主页面:
![]() |
![]() |
![]() |
![]() |
暗色主题:
![]() |
![]() |
截图里的设备数据只用于展示。
二、为什么是 Flutter + Rust
纯 Kotlin 也能写,但我想要的是「界面随便改,数据管道不许乱」。两边的职责切得很干净:
- Dart 侧:103 个文件、约 5.6 万行,负责页面、图表、主题、九种语言的文案
- Rust 侧:约 1.7 万行手写代码(另外还有 7000 多行由
flutter_rust_bridge自动生成的绑定),负责 SQLite 读写、睡眠重建、准备度计算、旧数据迁移、云同步 - Android 侧:264 个 Java / Kotlin 文件,大部分是华为手表的协议层
桥接面窄得出奇:Rust 只对外暴露 health、navigation、types 三个 API 模块,Dart 侧对应的 lib/src/rust/api/ 是生成出来的。界面不直接碰 SQL,所有写入都走固定入口,这样一来「数据怎么进来的」只有一条路径,测试也好写。
状态管理没用 Riverpod / Bloc 这类框架,就是一个 ChangeNotifier 加依赖注入,够用。这个项目里真正的复杂度不在 UI 层。
三、手表直连:RFCOMM + HiChain
同步走经典蓝牙 RFCOMM,认证走华为的 HiChain。协议实现改编自 Gadgetbridge 的华为 / 荣耀支持,源码里保留了 AGPL-3.0 的归属说明——这件事没有捷径,只能沿着别人已经趟过的包格式走。
关键 packet 大致是这些:AccountRelated(账号)、FitnessData(健康数据字典)、FileDownloadService0A / FileDownloadService2C(文件式下载)、P2P、GpsAndTime、Ephemeris 等。手表不是「查询接口」,而是「你发一个请求,它按自己的节奏回一批事件」,所以客户端要处理的是事件流,不是请求响应。
配对分两种情况,这是新用户最容易卡住的地方:
| 情况 | 需要什么 | 做法 |
|---|---|---|
| 手表从没绑过官方 App(或愿意恢复出厂) | 手机上的华为账号 | 直接首次配对 |
| 手表已绑官方 App,不想恢复出厂 | 旧版 App 迁移过来的 HiChain 凭据 | 安装旧版 App 完成迁移 |
首次配对时,App 会让用户输入手表当前绑定的华为账号,下发 SendExtendedAccountRequest,手表弹窗点「允许」,认证 key 协商完成后数据字典才会放行。
最容易踩的两个坑:
- 在官方 App 里解绑手表,认证 key 直接作废,只能恢复出厂重来
- 官方 App 和元环同时抢同一条蓝牙连接,会互相把对方踢下线
所以如果你之前用我的旧版同步工具配过表,元环会直接迁移 authkey / huawei_account / fake_android_id 这套凭据,不用重新配对;想换回官方 App,就卸载元环或者关掉它的后台。
四、数据先落本地,再谈云
所有数据先进设备内的 SQLite,Rust 通过 rusqlite 访问。表按「事实」和「派生」分开:
|
Android 层把手表事件序列化成 JSON 交给 Rust,ingest_watch_data() 在一个事务里落库。这里有两个必须守住的规则:
- 幂等:同一条事件重复到达,不能产生两条记录;删除用 tombstone 表达,云同步时才能把「删掉」这件事传播出去
- 权威值不被采样覆盖:手表给的「当天总步数」「昨晚睡眠总时长」是权威值,后来补上来的零散采样只能补细节,不能把总数改小
这两条听上去像废话,但真机同步里天天发生。store.rs 和 watch.rs 里有 34 + 19 条测试专门盯这些边界。
五、睡眠是最脏的一块
手表给的睡眠数据其实是两套东西:一套是总时长,一套是分期片段(深睡 / 浅睡 / REM / 清醒)。两者经常对不上:总时长 7 小时 12 分,分期加起来 6 小时 48 分。直接相加会虚高,直接信分期又会少算。
元环的做法是重建会话,而不是照抄:
- 按睡眠窗口的锚点把碎片合并成会话
- 白天的片段按 10 分钟间隔聚类,聚得起来的才算午睡,聚不起来的当日间噪声丢掉
- 分期用区间并集去重,避免同一分钟被算两次
- 分期总和和权威总时长不一致时,按比例缩放深睡 / 浅睡 / REM,保证三者之和等于手表给的总数
「按比例缩放」听起来不优雅,但这是唯一能同时满足「尊重手表总时长」和「展示分期构成」的做法。watch.rs 里 19 条测试有一半是在各种奇形怪状的片段上验证这件事。
六、「今天能不能练」到底怎么算
首页那个准备度分数,权重是显式写死的:
| 分项 | 权重 | 计算方式 |
|---|---|---|
| 睡眠 | 80% | 基础 80 分,6 小时为中性点,每多 / 少 1 分钟 ±0.15 分;深睡以 2 小时为中性点,每多 / 少 1 分钟 ±1 分;每次清醒 −5 分 |
| HRV | 7% | 与个人基线比,基线为 50 分,每高 1% 加 1.5 分 |
| 静息心率 | 4% | 以个人基线为 75 分,每低 1 bpm 加 3 分,每高 1 bpm 减 3 分 |
| 恢复时间 | 4% | 100 分起,当天运动恢复时间每 3 分钟扣 1 分 |
| 压力 | 3% | 压力指数直接反向映射 |
| 皮肤温度 | 2% | 偏离基线 0.3°C 以内不扣分,之后每 0.1°C 扣 4.5 分 |
三个设计决定值得说一下:
睡眠占 80% 不是拍脑袋。 其他指标要么噪声大(皮温、压力),要么采样稀疏(HRV 一晚也就几个点),睡眠是唯一稳定、每晚都有、且确实影响第二天状态的输入。
缺项不是补 0,而是重新归一化。 某个指标当天没有数据,就把它的权重剔掉,剩下的按比例重新分配。如果连身体数据都没有,恢复时间这一项会被直接排除——不能让「今天没运动」单独把分数抬到 100。
等级和建议分开写。 之前有过一版,等级说「适合正常训练」、建议说「控制强度」,同一张卡片自己打自己。现在 level_for() 只描述状态,advice_for() 只给行动,两边的阈值对齐。
手表自己算了睡眠分的,直接优先用手表分,不再用本地公式覆盖——官方对手表传感器的了解比我们多。
七、地图和长图,不依赖商业 SDK
运动详情里的轨迹地图用的是 PMTiles 单文件瓦片 + MapLibre + Protomaps,Liberty 亮色 / 暗色两套样式,橙色轨迹、公里编号、道路名称都做了。瓦片源由用户自己填,公开构建里不带任何地图服务地址。
也可以切到高德栅格底图;腾讯、百度的入口留着,给愿意自己配官方 SDK Key 的构建用。
长图分享是另一个体力活:滚动、拼接、等卡片渲染完成、把分享和导出按钮藏掉,最后按 18 兆像素封顶,并记住原来的滚动位置。这些细节不上心,导出的图就是断的、黑的、或者缺一段。
八、隐私不是口号,是构建参数
这个项目对隐私的处理方式比较硬:
- 云地址是编译参数:
--dart-define=VITALOOP_CLOUD_URL=https://sync.example.com,源码里没有默认值 - 不填地址,登录页也进不去账号功能,本地数据功能完全不受影响
- 权限按需申请,每项都有降级路径:拒绝定位就没有轨迹,拒绝通知就牺牲后台可靠性,但已经存在的本地数据永远能看
- 星历下载、实时轨迹、语音推流、相机推流、更新检查,在配置 URL 之前一律不联网
数据是自己的,给不给 AI 看也应该是自己的选择。所以服务端是另一件可选的、同样开源的东西,见下一节。
九、服务端也开源了:一个二进制、一个目录
客户端不登录就能用;如果你想把数据同步到自己的机器上,配套的服务端 vitaloop-server 也开源了。它和 App 用的是同一套语言:约 4600 行 Rust、37 条测试,编译出来是一个静态 musl 二进制,运行时只依赖 flate2(旧的 gzip 快照和瓦片上游要用)和 ureq(rustls)。容器阶段用 scratch——镜像里没有 shell,也没有包管理器。
它做四件事:
- 账号:邮箱 + 密码注册登录,密码存加盐 60k 轮 SHA-256,会话是 30 天有效的 Bearer token
- 同步:
GET/PUT /v1/health/data收全量快照(gzip 字节原样落盘,服务端不解析),PUT /v1/health/delta追加增量补丁日志,读取时再合并——客户端只上传变化,服务端不关心字段含义 - 运动与地图:运动记录按账号缓存;
/style.json、/tiles/{z}/{x}/{y}.mvt代理 Protomaps 的 PMTiles 并带本地缓存,App 里那套 MapLibre 地图就是从这里取瓦片 - 博客数据:
/blog/daily-fitness用独立 token 只读暴露一个账号的每日指标,这个博客的运动数据页面就是直接读它
存储没有数据库,就是文件树:
|
这个选择是有意的:备份就是 tar 一个目录,迁移就是 rsync 一个目录,彻底删除就是把目录删掉——不需要「联系客服注销账号」。
MCP 是里面最特别的一层。POST /mcp 走 Streamable HTTP + JSON-RPC 2.0,一条请求一条响应,不依赖 SSE;复用账号的 Bearer 会话,暴露 14 个只读工具:
| 工具 | 内容 |
|---|---|
health_summary |
近 N 天总览(体重、血压、血糖、睡眠、步数、静息心率、HRV、血氧、压力、体温) |
blood_pressure / blood_glucose / weight / temperature |
对应记录 |
sleep |
睡眠会话、卧床时间、官方评分与分期 |
hrv / stress / spo2 / heart_rate |
对应时序数据 |
workouts / daily_metrics / metric_points |
运动、每日汇总、通用指标 |
query_table |
白名单内的任意只读表 |
|
时间参数一律 Unix 毫秒,日汇总用 date_day(epoch 天),数值不做单位换算——语义保持和手表一致,换算交给调用方。「只读」也是硬约束:query_table 只接受白名单里的表,写操作一个都没有。
部署就一行:
|
生产上在前面挂 Nginx / Caddy 做 HTTPS。仓库里不带任何域名、主机地址或凭据,对外地址由反向代理的 Host 头推断,或者用 PUBLIC_BASE_URL 显式指定。
服务端和客户端都是 AGPL-3.0:随便用、随便改、随便商用;但如果改完当网络服务提供给别人用,得把完整源码给这些用户。服务端代码本身也说明了它不神秘——几千行、一个数据目录,没有隐藏的云,也没有后台服务。
十、跑起来
客户端:
|
签名只从环境变量读(VITALOOP_KEYSTORE 等四个),不写进仓库。
服务端:
|
目前 Rust 侧 105 条测试、Dart 侧 31 个测试文件,覆盖准备度边界、睡眠重建、存量数据迁移和同步策略。验证环境是一加手机(Android 10+)连 Huawei Watch GT 5;其他手表型号、其他厂商的后台省电策略,大概率还需要额外适配——这也是 AGPL 项目最欢迎的贡献方式。
仓库在这里:
最后说一句为什么做这件事:手表的数据是从自己身上长出来的。看可以交给别人,算,最好还是自己来。





评论
0 条评论