深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
中
ESC

元环:把华为手表的数据拿回本地

中文

手表记录了我很长时间的睡眠、心率、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 访问。表按「事实」和「派生」分开:

daily_metrics          每日汇总(步数、热量、静息心率、压力、体温…)
heart_rate_points 心率采样点
step_samples 步数采样
sleep_sessions 睡眠会话
sleep_stages 睡眠分期片段
hrv_records 夜间 / 手动 HRV
workouts 运动记录
metric_points 通用指标时序(血氧、呼吸率等)
blood_pressure_records / blood_glucose_records / arrhythmia_records
strength_exercises / strength_templates 力量训练
*_tombstones 删除标记

Android 层把手表事件序列化成 JSON 交给 Rust,ingest_watch_data() 在一个事务里落库。这里有两个必须守住的规则:

  1. 幂等:同一条事件重复到达,不能产生两条记录;删除用 tombstone 表达,云同步时才能把「删掉」这件事传播出去
  2. 权威值不被采样覆盖:手表给的「当天总步数」「昨晚睡眠总时长」是权威值,后来补上来的零散采样只能补细节,不能把总数改小

这两条听上去像废话,但真机同步里天天发生。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 只读暴露一个账号的每日指标,这个博客的运动数据页面就是直接读它

存储没有数据库,就是文件树:

<data>/accounts/<account_id>/health.json          全量快照(可能是 gzip)
<data>/accounts/<account_id>/health.patches.jsonl 增量补丁
<data>/accounts/<account_id>/workouts.json 运动表
<data>/accounts/<id>.json 账号记录
<data>/accounts/sessions 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 白名单内的任意只读表
curl -sS -X POST http://127.0.0.1:8793/mcp \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"blood_pressure","arguments":{"limit":20}}}'

时间参数一律 Unix 毫秒,日汇总用 date_day(epoch 天),数值不做单位换算——语义保持和手表一致,换算交给调用方。「只读」也是硬约束:query_table 只接受白名单里的表,写操作一个都没有。

部署就一行:

docker compose up -d     # 监听 127.0.0.1:8793,数据卷挂 /data

生产上在前面挂 Nginx / Caddy 做 HTTPS。仓库里不带任何域名、主机地址或凭据,对外地址由反向代理的 Host 头推断,或者用 PUBLIC_BASE_URL 显式指定。

服务端和客户端都是 AGPL-3.0:随便用、随便改、随便商用;但如果改完当网络服务提供给别人用,得把完整源码给这些用户。服务端代码本身也说明了它不神秘——几千行、一个数据目录,没有隐藏的云,也没有后台服务。

十、跑起来

客户端:

flutter pub get
flutter gen-l10n
flutter analyze
flutter test
(cd rust && cargo test)
flutter build apk --debug

签名只从环境变量读(VITALOOP_KEYSTORE 等四个),不写进仓库。

服务端:

docker compose up -d

目前 Rust 侧 105 条测试、Dart 侧 31 个测试文件,覆盖准备度边界、睡眠重建、存量数据迁移和同步策略。验证环境是一加手机(Android 10+)连 Huawei Watch GT 5;其他手表型号、其他厂商的后台省电策略,大概率还需要额外适配——这也是 AGPL 项目最欢迎的贡献方式。

仓库在这里:

最后说一句为什么做这件事:手表的数据是从自己身上长出来的。看可以交给别人,算,最好还是自己来。

文章作者:阿文
文章链接: https://www.awen.me/post/6fe3f8fa.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客!

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️✋ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐⛎ ♈♉♊ ♋♌♍ ♎♏♐ ♑♒♓ 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘❌⭕ 🛑⛔📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭❗ ❕❓❔ ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️✅ 🈯💹❇️ ✳️❎🌐 💠Ⓜ️🌀 💤🏧🚾 ♿🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️✋ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐⛎ ♈♉♊ ♋♌♍ ♎♏♐ ♑♒♓ 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘❌⭕ 🛑⛔📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭❗ ❕❓❔ ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️✅ 🈯💹❇️ ✳️❎🌐 💠Ⓜ️🌀 💤🏧🚾 ♿🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️