SomaLoop App 产品文档
V8 无屏手环的配套 App。手环没有屏幕,Agent 就是界面:它先开口、可追问、每句话带依据、记得你说过什么、没事就闭嘴,并且是市场上唯一会替你按下测量键的教练。本文档定义产品原则、信息架构、场景引导系统、Agent 交互规范、数据与算法层、视觉标准和演示稿计划。
昨夜恢复得不错,今天可以按计划跑第二次间歇
夜间心率 52,比你 14 天基线低 3;HRV 68 ms,在基线区间上沿。马拉松计划第 3 周:今天是 4 × 4 分钟,心率区 3 到 4。
PROTOTYPES两套交互方案
两套方案共享同一套设备、指标与 Agent 能力,只改变用户每天进入 App 后理解身体的方式。点击卡片可直接体验。
00一句话与目标
- 一句话。SomaLoop 是 V8 无屏手环的 Agent 伴侣:你说一件生活里的事(下个月跑马拉松、最近加班头疼、周末想爬山),它把手环变成那件事的传感器与提醒器,每天先开口,每句话可点开看依据,需要确认时替你按下测量键。
- DevKit for BioHacker。这只手环连同这个 App 一起,卖给的是想拿到自己原始生理数据、愿意动手做实验、也可能拿它去做产品的人。App 既是消费者能用的成品,也是开发者套件的参考实现:原始数据流、导出、依据、实验、MCP 端点是一等功能,放在 Me 页最显眼的位置,而不是藏在「高级设置」里。
- 不是什么。不是又一个无订阅的 WHOOP 仿品(厂商 JCVital 已经做了那一版),不是聊天框加仪表盘,不是医疗器械。
- 阶段目标。第一阶段用 HTML 演示稿在 15 分钟内证明三件事:Agent 能按下测量键;主动消息不废话且带依据;场景引导让一个从没用过健康 App 的人在 30 秒内知道该对它说什么。第二阶段落成原生 App,作为开发者套件的白标参考实现。
- 视觉与精细度对齐 WHOOP 与 Oura。深色、单一强调色、大数字环、克制的动效、每一个空状态与加载态都有设计。这是硬性要求,不是加分项。
01产品原则
十一家 AI 教练的形态与失败模式(App 调研合集第一、二部分)浓缩成七条。每条都会在验收里出现。
- Agent 就是界面。手环无屏,用户与身体数据的全部交互经由 Agent 发生。Today 页不是仪表盘,是 Agent 的时间线。
- 先说,再等你问。每天最多三张主动卡(醒来、运动后、睡前),加事件触发的推送;没有超出个人基线的变化就不生成。WHOOP 的原则:无事则静。
- 每句话可点开依据。任何含数字或结论的句子都附「依据」芯片,点开是原始数据段、时间戳与基线。后台统计接地率并展示。这是对 Google、WHOOP 最集中负评(引用不存在的数据)的直接回应。
- 能动手。Agent 可发起测量(心率、血氧、HRV、ECG)、设提醒(普通、吃药、喝水)、设久坐提醒、远程开启运动模式、写入记忆。七家大厂无一能按下测量键,这是唯一不用比模型聪明的地方。
- 记得,并且让你看见它记得。记忆分目标、健康史、生活事件、关注与忽略四类,可查、可改、可删;主动消息必须读记忆,否则重蹈 WHOOP Daily Outlook 的投诉。
- 说人话,不说病名。不出现房颤、呼吸暂停、高血压等疾病名,不做血糖与血压数值;说「节律不规则」「血氧低于你的基线」;所有输出附「健康参考,非医疗建议」。
- 数据永远免费,只卖解读。原始数据、导出、基础指标不收费;若有付费层,只放 Agent 解读、计划与长期指标(订阅分层报告结论)。
- 可拆开看,可接出去。面向 BioHacker 的承诺:每个分数都能拆到原始数据;每段数据都能导出(CSV / EDF / WFDB);实时 RR、PPG、ECG 流可以开给自己的程序;Agent 用的每个工具都以 MCP 暴露,用户可以换成自己的模型。App 里做的每一件事,开发者都能用 SDK 复现。
02用户与场景
首发用户
个人佩戴者,25 到 45 岁,戴过或考虑过 WHOOP、Oura、Fitbit Air,对「订阅制」和「只给分数不给原因」不满,愿意对一个 App 说自己的生活。机构版(护士站、班组、教练看队员)作为后续模块,本版只在信息架构里留位。
DevKit 用户
与首发用户重叠但动机不同:量化自我与生物黑客(自己写脚本看 HRV、做咖啡与睡眠的对照实验)、研究者与学生(要原始 ECG 与时间戳)、AI Agent 开发者(要 MCP 与事件流)、准备做产品的团队(把这个 App 当白标起点)。App 对他们的承诺见第一章第八条;专属入口在 Me 页的「Lab」区:原始数据流开关、导出、实验、MCP 端点与 API key、接地率与记忆保存率面板。演示稿里 Me 页要把这块做出来,因为它是「DevKit for BioHacker」这句话的证据。
核心用例
用户用一句话描述一件生活里的事,Agent 把它变成一个「玩法」(Playbook):明确目标、要收集什么数据、手环要做什么动作、每天什么节奏说话、什么时候结束并复盘。第四章定义了十二个玩法,三个是主演示。
03信息架构
四个标签加一个常驻输入框。Today 是 Agent 时间线,其余三个标签是 Agent 的「工作台」,每一屏右下角都能追问。
| 标签 | 是什么 | 主要内容 | Agent 在这里做什么 |
|---|---|---|---|
| Today | Agent 时间线,默认落地页 | 顶部三环(恢复、睡眠、负荷)与基线进度;按时间倒序的主动卡、测量卡、确认卡、系统事件;「试试问我」引导条;底部常驻输入框 | 主动开口、回应提问、发起测量、请求确认 |
| Body | 指标与基线 | 夜间五项基线(心率、HRV、呼吸率、皮温、血氧)各自的个人区间与今日位置;睡眠分期与贡献项;恢复分的白盒拆解;负荷 7 对 28 天比值;历史与导出 | 每张图下一句解释与「问这个」;点任何数字进依据视图 |
| Plans | 玩法与计划 | 进行中的玩法(如马拉松第 3 周)、今日任务、里程碑;玩法库(十二个入口);实验(如「咖啡与睡眠」两周对照) | 生成与调整计划、按恢复门控今日强度、复盘 |
| Me | 记忆、设备、Lab | 记忆页(目标 / 健康史 / 生活事件 / 关注与忽略);手环(电量、固件、自动监测时段、闹钟与久坐提醒);主动消息三旋钮(频率、长度、语气);Lab:原始数据流(RR / PPG / ECG)开关与实时波形、导出(CSV / EDF / WFDB)、实验、MCP 端点与 API key、接地率 / 依据覆盖率 / 记忆保存率面板、自带模型 key | 把用户改的记忆与旋钮立即应用到下一条消息 |
为什么不做「Coach」标签。Google 和 WHOOP 都证明了聊天页不该是一个目的地。SomaLoop 的对话发生在 Today 时间线里(主动卡下方直接回复)和每一屏的追问入口,不存在一个单独的「去和 AI 聊天」的地方。会话历史在 Me 里可查。
04场景引导系统
用户不知道该对一个健康 Agent 说什么,这是所有 AI 教练冷启动的共同问题。SomaLoop 用「玩法」把开放式对话变成有限的、可预期的入口,并在四个位置持续引导。
引导出现的四个位置
「你最近在忙什么?」
配对成功、基线开始积累时,不问年龄体重(那些从手环个人信息读),而是给六个场景芯片加一个自由输入框。选一个即建立第一个玩法,Agent 说明接下来两周它会做什么、什么还不能做(基线未满)。
按上下文轮换的三个建议
规则:周四周五推「周末计划」;连续三天就寝晚于基线推「熬夜」;步数或运动会话突增推「训练计划」;日间 HRV 连续偏低推「压力排查」;夜间皮温偏高推「像要生病」;日历有旅行推「倒时差」。每条点开就是一段预填好的用户消息,用户可改再发。
轮播的示例句
「下个月参加马拉松」「最近加班多有点头疼」「制定周末爬山计划」「咖啡到底影响我睡眠吗」……每 6 秒换一句,点击即填入。空状态和基线未满期间尤其重要。
Agent 看见变化,邀请你开一个玩法
「这三天你的睡眠中点比平时晚 1 小时 40 分,要不要一起做个两周的早睡实验?」邀请卡与主动卡同构,但结尾是一个开玩法的按钮,而不是反问。
玩法(Playbook)的统一结构
每个玩法是一份声明式配置,Agent 依此执行;开发者未来可以用同样结构写自己的玩法。
playbook:
id: marathon-4w
trigger: ["马拉松", "跑马", "半马", "全马", "参赛"] # 命中即建议开启
intake: [赛事日期, 目标完赛时间, 每周可训练天数, 最近一次长跑距离] # 一次问完,写入记忆
needs: [夜间心率, HRV, 睡眠分期, 运动会话心率, 步数] # 手环数据
actions: [开启跑步模式, 训练日晨间恢复门控, 长跑日前夜提醒, 赛前 3 天减量, 赛日 5:30 闹钟]
cadence: {morning: 训练建议, post_workout: 负荷与区间复盘, evening: 明日预告, weekly: 周报}
guardrails: [负荷比 > 1.4 强制降级, 夜间心率高于基线 8 bpm 连续两晚建议休息]
memory_writes: [目标: 10/12 马拉松 4:00, 生活事件: 训练周期]
end: {at: 赛后 3 天, deliver: 复盘卡 + 是否续接「恢复两周」玩法}
三个主演示玩法
下个月参加马拉松
SetDeviceMeasurementWithType);赛日 5:30 闹钟。最近加班多有点头疼
制定周末爬山计划
其余九个玩法(摘要)
| 玩法 | 触发 | 核心数据 | 手环动作 | 结束与复盘 |
|---|---|---|---|---|
| 想戒掉熬夜 | 熬夜 / 早睡 / 睡眠中点晚 | 就寝时刻、睡眠中点、规律性、睡眠债 | 就寝前 45 分钟提醒闹钟;周末不同时刻 | 14 天:中点前移分钟数、规律性分 |
| 出差倒时差 | 出差 / 时差 / 航班 | 睡眠时刻、日间 HRV、皮温节律 | 按目标时区分日推进的闹钟;抵达日 SpO2 | 回程后 3 天:节律恢复曲线 |
| 感觉要感冒了 | 感冒 / 不舒服 / 发烫 | 夜间皮温偏差、RHR、HRV、呼吸率推算 | 当夜加密皮温与 SpO2 自动监测;次晨 60 秒 HRV | 症状消失后:身体负荷信号回落图。措辞只说「身体负荷信号」 |
| 刚开完会有点心慌 | 心慌 / 心跳快 / 紧张 | 近 30 分钟心率与 HRV、RR 间期 | 60 秒 HR + HRV;不规则时建议 5 分钟 ECG 并保存 | 即时;ECG 记录进 Body 可导出 |
| 开始做力量训练 | 健身 / 力量 / 增肌 | 运动会话心率、恢复分、睡眠 | 运动模式 Workout;训练日次晨恢复门控 | 4 周:训练日与恢复的关系 |
| 咖啡到底影响我睡眠吗 | 咖啡 / 酒 / 实验 | 入睡潜伏期、深睡、夜间心率、HRV | 无;每日一条「今天喝了吗」快速芯片 | 14 天 A/B 对照卡,标注样本量不足时不下结论 |
| 给医生留一段心电记录 | 心电 / ECG / 医生 / 检查 | ECG 原始波形、RR、夜间心率 | 5 分钟 ECG 引导(按电极、保持静止、进度) | 即时;导出 PDF 与原始数据;不做任何判读 |
| 备孕,想看周期 | 周期 / 备孕 / 经期 | 夜间皮温偏差、静息心率、手动经期记录 | 无 | 两个周期后:皮温双相图。声明不可用于避孕或受孕判断 |
| 爸妈独居,想远程看着 | 父母 / 老人 / 远程 | 被看护者的夜间五项基线、SOS、久坐 | SOS 长按上报;夜间偏离先请求测量再通知家属 | 持续;进入机构版能力,本版只到「一个被看护者」 |
05Agent 交互规范
消息卡类型
| 类型 | 何时出现 | 结构 | 操作 |
|---|---|---|---|
| 主动卡 | 醒来 / 运动后 / 睡前,或事件触发 | 时间戳与触发原因 · 一句判断式标题 · 正文含加粗数字(≤ 3 句) · 1 到 3 条行动项 · 迷你图(可选) · 依据芯片 · 结尾反问(可选) | 拇指上下、回复、行动按钮(开运动模式 / 设提醒 / 今天休息) |
| 测量卡 | Agent 请求测量,或用户要求 | 为什么测 · 测什么与时长 · 姿势提示(ECG:把手指按在电极上) · 进度环 · 信号质量 · 结果与解读 | 开始 / 跳过 / 重测;结果自动进依据库 |
| 确认卡 | Agent 要做会影响他人或不可逆的事(发给医生、联系家属、改用药提醒) | 将要做什么 · 依据 · 两个明确按钮 | 确认 / 取消;留痕 |
| 邀请卡 | 数据变化建议开一个玩法 | 观察到的变化 · 建议的玩法 · 两周内会做什么 | 开始 / 以后再说(写入忽略 30 天) |
| 记忆回执 | Agent 从对话中写入记忆 | 「记下了:目标 · 10/12 马拉松 4:00」一行 | 编辑 / 删除 |
| 拒答卡 | 用户问诊断类问题 | 诚实说明手环数据不能回答 · 至少一个可执行动作(做 ECG 记录 / 打包数据给医生) | 动作按钮;不出现单独的免责声明卡 |
| 系统卡 | 基线进度、固件、电量、断连 | 一行,弱化样式 | — |
依据(Evidence)
每个含数据的句子绑定一个依据对象:指标、时间范围、数值、基线区间、图表类型。渲染为芯片,点开为底部抽屉,显示原始数据段(可缩放)、采样点、基线阴影区、数据来源(自动监测 / 即时测量)。没有依据的句子在开发者面板里标灰并计入未接地率。目标:含数据句子的依据覆盖率 100%,整体回答接地率 ≥ 80%(WHOOP 自报 56.6%)。
记忆
- 四类:目标、健康史、生活事件、关注与忽略。每条记录来源对话与时间,可编辑、可删除,删除后下一条消息立即不再引用。
- 写入回执必须出现在时间线里,用户看得见 Agent 记了什么。
- 主动卡生成前必须读取记忆;开发者面板显示「应保存上下文的实际保存率」与「主动卡引用记忆的比例」。
三个旋钮与静默原则
- 频率:少(只在偏离基线时)/ 标准(每天最多 3 张)/ 多(加上每次运动后与每周报)。默认标准。
- 长度:一句话 / 标准 / 详细。默认标准(标题 + 3 句 + 3 条要点)。
- 语气:直接 / 温和。默认直接。参照 Oura 的 direct / conversational。
- 静默:夜间 23:00 到 7:00 不推送(SOS 与用户设置的例外除外);近 7 天已说过的结论不重复;用户点过「今天休息」后当日不再催训练。
语言规范
- 数字先于形容词:「夜间心率 52,比基线低 3」而不是「你的心率很好」。
- 比较对象永远是本人基线,不是人群;基线未满时明说「还不知道」,不用人群区间冒充。
- 不做结论性判断(正常 / 没问题),只说相对变化与可能的生活因素。
- 一张卡最多一个反问句。
- 拒答后必须给动作。
06数据与算法层
全部确定性计算在手机侧完成,LLM 只做解读与编排。手环不跑 AI。
V8 能给什么
| 数据 | 粒度 | 用途 |
|---|---|---|
| 连续心率 | 每 5 秒(自动监测时段内) | 夜间心率基线、运动区间、TRIMP 负荷 |
| HRV 包(含压力、血压估算、血管老化度) | 约 30 分钟一条 | HRV 基线、日间压力;血压与血管老化度隐藏或标「参考」 |
| SpO2 | 自动周期 + 即时 | 夜间基线、高海拔场景 |
| 皮温 | 约 10 分钟一条 | 夜间偏差、生病信号、周期 |
| 睡眠等级 + 活动量 | 每分钟;单条最长 2 小时需拼接 | 分期、效率、中点、规律性、睡眠债 |
| 步数 / 距离 / 卡路里 | 每分钟 | 久坐、活动量、负荷近似 |
| PPI / RR 间期、原始 PPG、原始 ECG | 流式 / 会话 | 节律不规则检测、呼吸率推算、ECG 记录导出;RR 流影响续航 |
| 运动会话 | 18 种模式 | 训练负荷与区间分布 |
没有的:呼吸率字段(从 PPI 的呼吸性窦性心律不齐推算)、原始加速度流(做不了肌肉负荷)、GPS(配速用手机 GPS 补)、震动马达指令(「震一下」用一分钟后的闹钟替代,演示时如实说明)。
指标层(第一版范围)
- 夜间五项基线(心率、HRV、呼吸率推算、皮温、SpO2):7 到 30 天个人范围,超出即事件。这是 Agent 主动消息的主要事件源,也是 Body 页的第一屏。
- 睡眠分:参照 Apple 公开权重(时长 50 / 就寝规律 30 / 中断 20)做参考实现,贡献项全部可见。
- 恢复分:白盒,六项贡献(RHR 偏离、HRV 平衡、皮温偏离、恢复指数、睡眠、前日活动)各 0 到 100 并给量化说明。
- 负荷:TRIMP 加 7 对 28 天比值,分档抄 Garmin / Polar 公开阈值;不做 EPOC 与肌肉负荷。
- 日间压力:HRV 包加步数排除运动;粒度 30 分钟,标注。
- 校准期:所有分数 14 夜;期间显示「已积累 N / 14 夜」与已能看的原始趋势;第 14 夜主动推送「从今天起可以看恢复分了」并解释组成。
Agent 架构
- 内联注入(WHOOP 做法):当天摘要、基线、最近事件、记忆在 prompt 渲染时直接注入;只有「发起测量、设提醒、开运动模式、查历史、写记忆」走工具调用。
- 确定性优先(Google 做法):事件检测、基线、负荷比全部是算法输出,LLM 不做数值计算。
- 拒答门禁:合成 Persona 输入集覆盖疾病名提问,通过率作为发版前置。
- 模型可换:Claude、GPT、Gemini、DeepSeek、本地模型均可;开发者可自带 key。
07屏幕与关键流程
这是演示稿要做出来的清单。带「★」的是三个主演示流程必经的屏。
| 屏 | 内容要点 | 状态 |
|---|---|---|
| ★ 配对与首次引导 | 找到手环、连接动画、读取个人信息;「你最近在忙什么」六个场景芯片 + 输入框;说明未来两周会发生什么 | 连接中 / 失败 / 成功 |
| ★ Today | 三环 + 基线进度;时间线(主动卡、测量卡、确认卡、邀请卡、记忆回执、系统卡);「试试问我」条;常驻输入框 | 基线未满态 / 常态 / 手环未连接态 / 空状态 |
| ★ 测量流程 | 测量卡 → 姿势提示(ECG 电极示意)→ 进度环与实时信号质量 → 结果卡 → 回到对话。心率 / HRV 60 秒,SpO2 30 秒,ECG 5 分钟 | 进行中 / 质量差重测 / 用户取消 |
| ★ 依据抽屉 | 原始数据段、采样点、基线阴影、来源、时间戳;可切换到 Body 对应页 | — |
| Body 总览 | 夜间五项基线卡(今日值落在个人区间条上);睡眠、恢复、负荷入口 | 基线未满态 |
| Body 详情(睡眠 / 恢复 / 负荷 / 五项) | 大数字环 + 贡献项 + 7 / 30 / 90 天趋势;每张图下一句解释与「问这个」 | — |
| ★ Plans | 进行中玩法卡(进度、今日任务、护栏状态);玩法库;实验 | 无玩法空状态 |
| 玩法详情 | 目标、节奏、手环动作清单、周报、复盘 | — |
| Me · 记忆 | 四类列表,每条带来源与时间,编辑 / 删除 | 空状态 |
| Me · 手环 | 电量与充电、固件、自动监测时段、闹钟与久坐提醒清单、SOS 联系人 | 未连接 |
| Me · 主动消息 | 三个旋钮、静默时段、每日上限预览 | — |
| Me · 开发者面板 | 接地率、依据覆盖率、记忆保存率、主动卡点开率、测量请求接受率;MCP 端点与 API key | — |
08设计语言
对齐 WHOOP 与 Oura 的标准,具体到可以验收的规则。页首的小样是按这套规则做的 Today 页。
--bg #0E1416底色,近黑带青灰,不用纯黑
--card #151D1F卡片,比底色亮一档,1px 描边 #223032
--accent #4FB8C4唯一强调色(Agent、按钮、依据)
--recovery #7BE0A8恢复环;黄 #E0A94A 负荷;红 #F27B6E 仅偏离
--ink #E9EEEC正文;次级 #C3CFCC;弱化 #8A9A97
agent-dotAgent 身份:青到绿的小圆点,不做人像
字体与数字
正文 IBM Plex Sans + Noto Sans SC;所有数值与时间戳用 IBM Plex Mono 等宽并开 tabular-nums;环内大数字 28 到 40 px,字重 600,字距 -0.02em;标签全大写 9 到 11 px 加 0.14em 字距。
图形
环:7 px 描边,背景轨道 #1C2628,进度用单色不用渐变;只有恢复、睡眠、负荷三个环,其他指标一律用「个人区间条 + 今日点」。迷你图:柱高不超过 44 px,当前值高亮为强调色,其余 #2B3A3D。基线区间用 8% 透明度的强调色阴影。
动效
环在页面出现时 600 ms 从 0 画到值,缓动 cubic-bezier(.2,.8,.2,1);测量进行中用呼吸式脉冲(1.6 s 周期);卡片进入 240 ms 淡入上移 8 px;尊重 reduce motion。不做粒子、不做 3D。
精细度清单
- 每一屏都有空状态、加载态、错误态、未连接态四种设计。
- 基线未满是常态不是异常:进度条、能看什么、还不能看什么,三样必须同屏。
- 文字层级不超过四级;一张卡上最多两种字号。
- 触控目标 ≥ 44 px;输入框常驻但不遮挡最后一张卡。
- 深色为默认,提供浅色但两套都要过对比度 4.5:1。
信号质量 良好 · 手环贴合正常
你说「刚开完会有点心慌」。近 30 分钟自动监测的心率均值 89,比你日间基线 71 高 18,但采样稀疏,我需要一段连续 60 秒的数据才能判断。
测完我会告诉你:此刻心率与 HRV 相对你基线的位置,以及更像应激还是身体异常。我不会用疾病名下结论。
09边界与合规
- 非医疗器械,仅供健康参考与研发;所有 Agent 输出附「健康参考,非医疗建议」。
- 不出现疾病名;不做血糖、血压数值;HRV 包里的血压与血管老化度隐藏或标「参考」。
- 不用人群阈值冒充个人基线;基线未满时明说。
- 发给医生、联系家属、改用药提醒必须人确认并留痕。
- 数据默认留在手机;云端可选且可自建;明示不用于训练。健康数据按敏感个人信息处理,单独同意。
- 拒答门禁通过率作为发版前置;接地率作为发版门禁指标。
10度量与验收
| 指标 | 定义 | 目标 | 为什么 |
|---|---|---|---|
| 依据覆盖率 | 含数据句子中绑定依据对象的比例 | 100% | 原则 3 |
| 接地率 | Agent 回答中引用了用户真实数据的比例(人工抽检 + 自动) | ≥ 80% | WHOOP 自报 56.6% |
| 记忆保存率 | 应保存的上下文实际写入记忆的比例 | ≥ 70% | WHOOP 自测 46% |
| 主动卡引用记忆率 | 主动卡中至少引用一条记忆的比例(有记忆时) | ≥ 90% | WHOOP Daily Outlook 投诉 |
| 测量请求接受率 | Agent 请求测量后用户开始的比例 | ≥ 60% | 验证「能动手」被接受 |
| 主动卡点开率 / 关闭率 | 被展开或回复的比例 / 被点「少一点」的比例 | ≥ 40% / ≤ 5% | 避免 Garmin 式废话卡 |
| 拒答门禁通过率 | 合成 Persona 疾病名提问被正确拒答并给动作的比例 | 100% | 发版前置 |
| D14 留存 | 基线满 14 夜时仍每日佩戴的用户比例 | ≥ 70% | 校准期是流失高峰 |
| 玩法开启率 | 7 天内开启至少一个玩法的新用户比例 | ≥ 60% | 场景引导是否有效 |
11路线与演示稿计划
阶段
- HTML 演示稿(本文档确认后开始):手机尺寸的可点击原型,深色,按第八章规则实现;三条主演示流程可完整走通;数据为脚本化的假数据,但结构与真实 SDK 字段一致;可部署到 ecg-devband-report.pages.dev/app/demo。
- 原生 App:建议 Flutter 一套代码,通过平台通道桥接 V8 的 iOS 静态库与 Android aar;BLE 连接与历史同步(每批 50 组、断点续传)在原生层;指标层与 Agent 编排在 Dart 层;多设备并发演示前必须真机实测。若团队更熟原生,则 iOS Swift + Android Kotlin 双写,SDK 直接调用。
- 白标化:品牌、配色、玩法库、旋钮默认值全部配置化,成为开发者套件的参考 App。
演示稿的三条主线(15 分钟)
我说心慌,它让手环测一次
- Today 常态页,输入「刚开完会有点心慌」
- Agent 给已有证据 + 测量卡
- 60 秒进度环与信号质量
- 结果卡:92 对基线 71,HRV 低三成,「更像应激」
- 点依据芯片看原始曲线
- 选「设 20 分钟后喝水提醒」→ 记忆回执
下个月参加马拉松
- 点「试试问我 · 下个月参加马拉松」
- 一次性 intake 四问
- Plans 出现玩法卡与第一天任务
- 快进到第 3 周:早卡恢复门控(一天练、一天降级)
- 运动后一句话复盘
- 周报里的负荷曲线
周末爬山
- 周五睡前预告卡
- 周六「开始徒步模式」
- 山顶 SpO2 测量卡(30 秒)
- 周日恢复卡把昨天与今天连起来
剩余 2 分钟:Me 页展示记忆与三个旋钮,再进 Lab 打开一条实时 RR 流、导出一段 ECG、展示 MCP 端点与接地率,说明「这就是 DevKit for BioHacker:消费者能用,开发者能拆」。
可点击的 HTML 演示稿:ecg-devband-report.pages.dev/app/demo(核心页与核心交互,假数据,结构与 SDK 字段一致)。
需要你确认的四件事。① 四个标签的命名(Today / Body / Plans / Me)与不设单独 Coach 页;② 首发只做个人版,机构视角留到下一版;③ 十二个玩法的清单与三个主演示的选择;④ 设计语言的配色与「只有三个环」的克制。确认后我开始做演示稿。
SomaLoop App 内容清单
按页面与板块,罗列 MVP 要展示的每一条内容:它从哪来、对应哪个字段、现在做到哪一步。写这份的起因是界面上开始出现「这个数到底是谁算的」的模糊地带——服务端算的分数、手环读的事实、用户自己填的偏好,三者必须在每一条上分得清。
00三个来源
界面上任何一个数字,来源必须是下面三者之一。混着说就会出现「这个数到底是谁算的」。
| 来源 | 给什么 | 拿法 | 现状 |
|---|---|---|---|
| 手环 SDK | 心率、夜间心率、HRV、血氧、皮温、步数、睡眠分期、原始包 | DeviceCoordinator.synchronise() 逐流读历史 | 已接;睡眠流之前没在同步清单里(已修),分期仍未解析成区间 |
| 服务端计算 | 恢复分、睡眠分、负荷及三者的分项、稳定度、覆盖率 | GET /me/days,读时现算 | 已接,2026-09-28 |
| 本地与用户输入 | 昵称、睡眠目标、提醒开关、佩戴位置、打卡、测量备注 | LocalStore meta 表 | 已接;睡眠目标/提醒/安静时段已同步到服务端 |
两条不能违反的规矩。① 分数一律由服务端算,App 不自己算、也不缓存成静态值——服务端是读时现算的,内部多取 29 天历史做个人基线。② 距离与热量是按步数折算的(步长 0.72 m、每步 0.04 kcal),不是单独测的,标注上必须说明。
01状态词汇
同一个「没有数」,原因不同,说法就得不同。这套词汇全 App 统一。
缺失的四种原因
| 原因 | 判据 | 文案 |
|---|---|---|
| 还没登录 | 无 refresh token | 评分由服务端生成,登录后同步 |
| 还没同步到 | 已登录但本地无该日 server 行 | 评分由服务端生成,同步后显示 |
| 覆盖不足 | scores.status = insufficient_coverage | 这一夜心率覆盖不足六成,算不出恢复分 |
| 没检测到睡眠 | scores.status = no_sleep_detected | 这一夜没有检测到睡眠 |
稳定度四态
服务端返回 scores.stability.level,App 只按它换文案与颜色深浅,不改数值。接入清单明写:首页三卡按它显示。已实现(2026-09-28)。
| level | 夜数 | 卡片上怎么说 |
|---|---|---|
initial | 1–2 | 初步 · 主要看睡眠 |
building | 3–6 | 校准中 · 基线还在积累 |
settling | 7–13 | 接近稳定 |
stable | ≥ 14 | (不加标签,直接给数) |
冷启动的加权由服务端处理:心率与 HRV 两项乘以 w = n/(n+3),缩掉的份额由当夜睡眠分补上。第 1 晚 w=0.25,第 14 晚 0.82。所以第一晚就有数,没有「空白→突然出数」的跳变,App 不需要自己做冷启动逻辑。
其他状态
partial_day:当天心率覆盖不足六成(首日佩戴、白天没戴)。load_7d不计这类天。status ∈ provisional | final | revised:稳定度 stable 且非 partial_day 才是 final。修订过的日报要能看出来。- 来源优先级:手环 > 服务端 > Apple 健康 > 模拟。同一指标多来源不求和,按优先级取一个。
- 历史时区未知时保留未知,不把手机时区套进旧数据。
02首页头部
tab 以上的一整块,四个栏目共用。
报头
- 字标 · 通知铃铛(有未读时带点)· 头像(昵称首字,未设时「T」)
状态行
| 内容 | 字段 | 来源 | 现状 |
|---|---|---|---|
| 连接状态点 + 「已连接 / 未连接」 | DeviceConnectionState | SDK | 已做 |
| 电量 | readBatteryPercent() | SDK | 已做,未连接时不显示 |
| 最后同步时间 | last_sync_at | 服务端批次回执 | 未做(接入清单要求顶栏显示) |
| 同步按钮(小) | — | 动作 | 已做,同步中转圈 |
Hero 左栏
| 内容 | 怎么来的 | 现状 |
|---|---|---|
| 问候语(按钟点) | 本地时间 + 昵称 | 已做 |
| 状态标题「状态正好 / 慢一点 / 和平时一样」 | 主指标对个人基线的偏离 | 已做;点击打开依据 |
| 下一行:今天偏离常态最远的那一项 | 候选项按 |deviation| 排序,≥0.35 才出 | 已做;每天都会变,点进该指标 |
| 「测一次」按钮 | — | 已做,开半高抽屉 |
Hero 右栏 · 设备场景
- 佩戴位置手腕 / 上臂:用户自选的设置,不是识别。存本地,服务端无对应字段。原型上的「已识别 · 贴合良好」需要佩戴检测,SDK 没有这个能力。
- 手环佩戴图:静态素材。
- 心电纸网格 + 走动的迹线:心搏间隔由实测心率算(60/bpm),心率高则走得密。波形不是录下来的心电——这条链路没有连续 ECG 流。
- 静息心率读数:
resting_heart_rate。服务端定义是睡眠窗内心率 5 秒点的 25 分位;现在用的是手环日汇总,接拉取后换成服务端值。
三张指标卡
恢复 / 睡眠 / 负荷。三项都是服务端评分,未登录时全空——那时按优先级换成手环确实读到的指标顶上(心率、变异性、步数、血氧)。每张卡五层:
| 层 | 恢复 | 睡眠 | 负荷 |
|---|---|---|---|
| 图标 + 标签 | 恢复 | 睡眠 | 负荷 |
| 大数字 + 单位 | 86 分 | 7:42 | 3.8 |
| 副标签 | 恢复程度 | 小时 · 昨夜 | 今日活动 |
| 小图形 | 20 根细柱成拱,按分数填到某一根 | 分期阶梯线,四段宽度来自实际时长 | 7 根粗柱,最后一根最实 |
| 状态行 | 高于/低于你的常态 | 睡眠分 92 | 目标 8–12 |
缺的:状态行应该按 stability.level 显示「初步 / 校准中 / 覆盖不足」,现在只有「有数」和「—」两态。
03洞察
回答「今天怎么样,凭什么这么说」。
日期切换
前一天 / 后一天 / 日历 / 回到今天。范围 90 天。
话题与趋势
- 四个话题 chips:恢复 / 睡眠 / 心率 / 步数。
- 选中项的 14 天趋势图 + 区间(最小–最大)+ 今日值。缺数的天不补点。
- 结论段落:复述读数与口径,不加判断。例如恢复度那段会说明它由睡眠、静息心率与 HRV 共同得出,且和「今天该不该运动」不是一回事。
亮点(Apple 健康 Highlights 的做法)
自动挑出这周确实变了的最多三项,每条是「本周 X 平均 A,上周是 B」+ 14 天柱图 + 变化百分比。两周各至少 3 天有数、相对变化 ≥ 4% 才入选;够不上就不摆。
身体信号四格
静息心率 / 心率变异性 / 血氧 / 皮温。每格:名称、大数字、个人区间条、区间说明。
区间条用的是这个人自己 14 天的最小最大值,不是人群参考范围。我们没有人群数据,画一条「正常范围」是编的。服务端的基线也是同一个原则:先验只是冷启动的起点,14 晚后完全按个人基线。
皮温要改:服务端给的是 skin_temperature_deviation_c(相对近 7 天中位数的偏差),现在当绝对值在显示,语义不对。
今天的节奏
手环这一天确实记下来的几件事,时间取自数据本身,不是编的时刻表:醒来(睡眠起止 + 时长对目标)、活动(步数 + 折算距离)、静息心率(日间 + 夜间)。
其余
- 入睡提醒入口(本机提醒,不上传)
- 全部指标入口(弹层)
- 「这些结论对你有用吗」——有用 / 没用,目前只存本地
- 免责声明
04长寿
几周以上的变化。单天的起伏在这里被抹平。
| 板块 | 内容 | 来源 | 现状 |
|---|---|---|---|
| 28 天覆盖 | 睡眠 / 恢复 / 步数 / 变异性,各「28 天中有 N 天有记录」 | 本地库数行数 | 已做 |
| 最近两周 | 逐日柱图 + 区间 | 本地 | 已做 |
| 按周趋势 | 恢复度按周均值,4 周 / 12 周切换 | 本地聚合 | 已做;接拉取后应改用服务端的日报序列 |
| 按周看 | 睡眠 / 心率 / 变异性 / 步数,本周均值与上周比 | 本地聚合 | 已做 |
| 打卡 | 午后步行、23:00 前入睡 | 本地 | 已做 |
| 周回顾 | — | — | 未做 |
打卡应该走 POST /me/events {kind:"habit_checkin", ts, attrs:{habit}},现在只存本地。event.kind 还支持 reminder / note / medication / supplement / illness / travel——生活记录那一大块的落点就是这个接口。
05睡眠
| 板块 | 字段 | 现状 |
|---|---|---|
| 昨夜时长 + 起止 | sleep.total_minutes、sleep.start/end | 已做(本地推算,应换服务端值) |
| 对目标的差额 | + 偏好 sleep_target_h | 已做 |
| 分期条 + 四段时长 | sleep.stages[] {stage, minutes},deep/light/rem/awake | 已做(本地推算) |
| 睡眠分四分项 | scores.sleep.{duration 50, continuity 20, depth 20, regularity 10} | 已做——四项各带占比条 |
| 两周统计 | 有记录的夜 / 平均时长 / 睡眠债 | 已做,睡眠债是真算的 |
| 多段睡眠、午睡、跨午夜整理 | — | 未做——睡眠流只存了原文,未解析成区间 |
| 入睡提醒 | 本地 | 已做 |
之前这里写错过一句话。我一度在界面上说「睡眠分的构成拿不到,所以不拆开给你看假的权重」。服务端是给拆解的——四个分项都在 soma.daily/v1 里。真实情况是我们还没接 GET /me/days。已改正。
06Lab
重点是 agent 接入,不是原始记录的计数器。回答「别的程序怎么用到这些数据」。
- 能调的工具:measure / daily / series / raw / sync。可用与否问设备层要,不是写死的清单——心电在这只固件上调不通就标「不可用」。
- 调用示例:代码块,说明返回结构与上传契约同一套(
soma.sample/v1、soma.measurement/v1),字段名一个字不差。 - 本地存了什么:原始记录数、等待上传数、有记录的天数、手环与固件。
- 导出:CSV(带时间戳与来源)+ 系统分享。
未做:PPG / PPG+ACC 原始采集与会话管理、信号预览、缺包计数、采集模式与 SDK 版本标注、原始数据与日志导出。原始采集与历史同步不能并行(SDK 会 busy),这个互斥要在界面上表达。
07弹层
全部指标
11 项按主题排列(恢复/变异性 · 睡眠/夜间心率/皮温 · 心率/血氧 · 步数/距离/热量/负荷),每行:圆底图标、名称、口径说明、7 天迷你折线、当日值。带搜索。
指标详情
- 时间范围 7 / 30 / 90 天
- 当日值 + 口径
- 柱状趋势图 + 「N 天中有 M 天的记录」
- 这段时间的平均 / 最低 / 最高
未做:7/14/28 天对比(需求里明确要三档)、数据来源与采集/同步时间标注、覆盖率、过期与部分同步状态。
测量
半高抽屉列出四项,按固件能力灰掉不支持的并写明原因。选一项进具体流程。
| 类型 | 要展示 | 现状 |
|---|---|---|
| 心率 | 佩戴说明 → 开始 → 进度 → 读数 → 保存/备注/重测/历史比较 | 调真 SDK;结果与失败原话都显示 |
| 血氧 | 同上,且要显示同批返回的心率 | 已接 supporting 字段 |
| HRV | 历史与同来源趋势;手动测量是实验入口,不擅自用 RMSSD 或 ms 单位 | 部分 |
| ECG | 接触说明、真实原始波形、停止、数据与日志保存;不提供医疗判读 | 受阻,见第 09 节 |
未做:类型/日期筛选、前后比较、重复点击保护、设备占用提示、有效与失败记录分开、取消/停止二次确认、按测量类型验证信号质量。不能以倒计时结束冒充有效结果——现在走的是真 SDK 返回,这条满足。
设备
| 内容 | 现状 |
|---|---|
| 连接状态、电量、固件版本、可读数据流 | 已做,全部从连上的手环读 |
| 同步状态、手动同步 | 已做 |
| 账号绑定(claim)、上传回执 | 已做 |
| 搜索/识别/解绑/断连重连/失败重试 | 部分——扫描与连接有,解绑与重试策略没有 |
| 设备时间、自动测量配置、已有闹钟读取 | 未做 |
| 校时、设置写入、设备闹钟、自动测量间隔、固件升级、恢复出厂 | 待 SDK 支持 |
我与设置 · 登录
- 昵称、睡眠目标、每日结论提醒、夜间安静时段、账号登录/退出、数据去向说明、版本与免责
- 登录:手机号 + 验证码,两份同意(service / health_data)带
text_version
未做:PATCH /me {birth_year, sex}——负荷的心率区间要年龄(HRmax = 208 − 0.7×年龄),不填则退化成步频档;也是拒绝 14 岁以下注册的依据。PATCH /me {preferences}——睡眠目标、Insight 推送、安静时段服务端都有字段,现在只存本地。身高体重、体重历史、健康目标、数据处理授权与撤回、导出与删除,全部未做。
08需求对照
团队 MVP 需求九大项,各自落在哪一页、做到哪一步。
| 需求项 | 落点 | 状态 |
|---|---|---|
| 1 设备管理 | 设备弹层 · Lab | 连接/能力/同步/绑定已做;解绑、重试策略、设备时间与闹钟未做;校时与写入待 SDK |
| 2 生理测量 | 测量弹层 | 心率/血氧走真 SDK;HRV 部分;ECG 受阻;筛选、比较、占用保护未做 |
| 3 数据监控 | 洞察 · 长寿 · 指标详情 | 七项指标与趋势已做;睡眠分期未解析;7/14/28 三档、覆盖率与来源标注未做 |
| 4 Insight | 洞察 | 每日摘要、信号变化、亮点周对比、有用/没用已做;周回顾、减少同类、去重、推送偏好未做 |
| 5 活动计划与生活记录 | — | 整块未做。落点是 POST /me/events |
| 6 提醒与触觉 | 睡眠 · 设置 | 入睡提醒本地已做;其余提醒未做;SDK 振动未做 |
| 7 数据管理与 Lab | Lab | 本地存量与 CSV 导出已做;原始采集会话、信号预览、缺包计数未做 |
| 8 用户管理 | 设置 · 登录 | 昵称与偏好本地已做;档案、体重历史、授权撤回、导出删除未做 |
| 9 系统设置 | 设置 | 外观与减少动效跟随系统;语言、单位、时间格式、权限、缓存、日志、帮助未做 |
需求里两条原则已经落到界面上:无聊天框、无教练对话、无对话记忆(偏好只通过明确设置维护);演示数值必须属于 mock(启动参数 dev-data 才用模拟数据,不带参数时读不到的显示「—」,并在依据页标明当前是否演示数据)。
10完整数据清单
把三个来源能给的每一项都摆出来,逐条对一遍它在界面上的落点。重点是「展示位置」那一列——写「未展示」的,就是拿得到却没用上的。共 144 项,其中 94 项未展示。
服务端 · 日报 soma.daily/v1(GET /me/days)
| 字段 | 含义 / 单位 | 来源 | 展示位置 | 状态 |
|---|---|---|---|---|
| local_date | 本地日 yyyy-MM-dd | 服务端 | 未展示(已落库,日期切换仍用本机日历) | 已拉取 |
| tz | 该日时区 | 服务端 | 未展示(已落库) | 已拉取 |
| status | provisional | final | revised | 服务端 | 未展示(已落库,provisional/final 尚未区分) | 已拉取 |
| sleep.start | 入睡时刻,UTC | 服务端 | 未展示(已落库,昨夜起止仍为本地推算) | 已拉取 |
| sleep.end | 起床时刻,UTC | 服务端 | 未展示(已落库,同上) | 已拉取 |
| sleep.total_minutes | 总睡眠分钟 | 服务端 | 睡眠 · 昨夜大数字 | 已做 |
| sleep.stages[] | 分期区间数组 | 服务端 | 未展示(睡眠 · 分期条现按固定比例推算) | 未做 |
| sleep.source | 固定 device | 服务端 | 未展示 | 未做 |
| resting_heart_rate | 静息心率 次/分,睡眠窗内心率 5 秒点的 25 分位 | 服务端 | 洞察 · 身体信号 / hero 读数 | 已做 |
| steps | 当日步数 | 服务端 | 全部指标 / 今天的节奏 | 已做 |
| active_minutes | 活动分钟 | 服务端 | 未展示(已落库) | 已拉取 |
| skin_temperature_deviation_c | 皮温相对近 7 天中位数的偏差 °C | 服务端 | 洞察 · 身体信号四格(已按「偏差」呈现) | 已做 |
| last_spo2 | 当日最近一次血氧 | 服务端 | 洞察 · 身体信号四格 | 已做 |
| last_hrv | 当日最近一次 HRV | 服务端 | 洞察 · 身体信号四格 | 已做 |
| coverage.heart_rate_minutes | 当日心率覆盖分钟 | 服务端 | 未展示(已落库) | 已拉取 |
| coverage.expected_minutes | 当日应覆盖分钟 | 服务端 | 未展示(已落库) | 已拉取 |
| recovery | 恢复分 0–100 | 服务端 | 首页头部 · 三张卡 | 已做 |
| load | 负荷,无量纲 | 服务端 | 首页头部 · 三张卡 | 已做 |
| load_7d | 负荷 7 天均值 | 服务端 | 未展示(已落库) | 已拉取 |
| sleep_score | 睡眠分 0–100 | 服务端 | 首页头部 · 三张卡 / 睡眠 · 睡眠分 | 已做 |
| partial_day | 当日心率覆盖不足六成 | 服务端 | 未展示(已落库) | 已拉取 |
| scores.algo_version | 算法版本,当前 daily-v0 | 服务端 | 未展示(已落库) | 已拉取 |
| scores.status | ok | insufficient_coverage | no_sleep_detected | 服务端 | 三张卡状态行 / 睡眠 · 睡眠分说明 | 已做 |
| scores.stability.nights | 已入基线的夜数 | 服务端 | 首页头部 · 三张卡状态行(第 N 晚) | 已做 |
| scores.stability.level | initial | building | settling | stable | 服务端 | 首页头部 · 三张卡状态行(初步/校准中/接近稳定) | 已做 |
| scores.sleep.duration | 睡眠分 · 时长项,满分 50 | 服务端 | 睡眠 · 睡眠分四分项 | 已做 |
| scores.sleep.continuity | 睡眠分 · 连续性项,满分 20 | 服务端 | 睡眠 · 睡眠分四分项 | 已做 |
| scores.sleep.depth | 睡眠分 · 深度项,满分 20 | 服务端 | 睡眠 · 睡眠分四分项 | 已做 |
| scores.sleep.regularity | 睡眠分 · 规律项,满分 10;不足 7 夜为 null | 服务端 | 睡眠 · 睡眠分四分项 | 已做 |
| scores.recovery.rhr | 恢复分 · 静息心率项,满分 40 | 服务端 | 未展示(已落库,恢复分依据页未做) | 已拉取 |
| scores.recovery.rhr_z | 静息心率对个人收缩基线的 z 分 | 服务端 | 未展示(已落库,同上) | 已拉取 |
| scores.recovery.hrv | 恢复分 · HRV 项,满分 30 | 服务端 | 未展示(已落库,同上) | 已拉取 |
| scores.recovery.hrv_z | HRV 的 z 分 | 服务端 | 未展示(已落库,同上) | 已拉取 |
| scores.recovery.sleep | 恢复分 · 睡眠项,满分 30 | 服务端 | 未展示(已落库,同上) | 已拉取 |
| scores.recovery.temp_penalty | 皮温升高的扣分 | 服务端 | 未展示(已落库,同上) | 已拉取 |
| scores.recovery.baseline_weight | 冷启动权重 w = n/(n+3) | 服务端 | 未展示(已落库,同上) | 已拉取 |
| scores.flags | 该日标志数组 | 服务端 | 未展示(已落库) | 已拉取 |
| contributing_devices | 贡献该日数据的设备 ULID 列表 | 服务端 | 未展示(已落库,用作 device_id) | 已拉取 |
38 个字段,一个都没读——GET /me/days 还没接。括号里写的是同一个位置现在显示的东西:要么是手环日汇总,要么是本地按固定比例推算的。
服务端 · 其余读取接口
| 字段 | 含义 / 单位 | 来源 | 展示位置 | 状态 |
|---|---|---|---|---|
| /me · preferences.sleep_target_h | 睡眠目标,小时 | 服务端 | 未展示(弹层 · 我与设置读的是本地值) | 未做 |
| /me · preferences.insight_notifications | 每日结论提醒开关 | 服务端 | 未展示(同上) | 未做 |
| /me · preferences.quiet_hours | 夜间安静时段开关 | 服务端 | 未展示(同上) | 未做 |
| /me · birth_year | 出生年;负荷的心率区间要它 | 服务端 | 未展示(没有任何填写入口) | 未做 |
| /me · sex | 性别;基线先验与 14 岁下限的依据 | 服务端 | 未展示(同上) | 未做 |
| /me · last_sync_at | 最后同步时间 | 服务端 | 未展示(首页头部 · 状态行只有本地的「刚刚同步」字样) | 未做 |
| sample.metric | heart_rate | spo2 | steps | skin_temperature | hrv | 服务端 | 未展示(只上传,不回读) | 未做 |
| sample.value / sample.unit | 点值与单位 | 服务端 | 未展示(同上) | 未做 |
| sample.ts | 采样时刻,UTC | 服务端 | 未展示(同上) | 未做 |
| sample.origin | realtime | measurement | history | 服务端 | 未展示(弹层 · 指标详情要求标来源,没标) | 未做 |
| sample.quality[] | low_perfusion | motion_artifact | loose_fit | reconstructed | clock_corrected | imported | 服务端 | 未展示 | 未做 |
| sample.device_id | 服务端签发的设备 ULID | 服务端 | 未展示 | 未做 |
| sample.record_id | {kind}:{deviceDate}[:i] | 服务端 | 未展示 | 未做 |
| series.step_ms | 序列点间隔 ms;心率 5000、步数 60000 | 服务端 | 未展示 | 未做 |
| series.values[] | 序列值,缺失位置填 null 不删元素 | 服务端 | 未展示(所有趋势图按天只取一个值) | 未做 |
| interval.kind | sleep_stage | activity | wear | workout | 服务端 | 未展示 | 未做 |
| interval.start_ts / end_ts | 区间起止,UTC | 服务端 | 未展示 | 未做 |
| interval.attrs.stage | deep | light | rem | awake | unknown | 服务端 | 未展示(睡眠 · 分期条现按固定比例推算) | 未做 |
| interval.attrs.code | 设备原始分期码 | 服务端 | 未展示 | 未做 |
| measurement.metric | heart_rate | spo2 | hrv | 服务端 | 未展示(只上传,不回读) | 未做 |
| measurement.status | ok | failed | cancelled | 服务端 | 未展示(弹层 · 测量只显示这一次的结果) | 未做 |
| measurement.value.value / .unit | 测量读数与单位 | 服务端 | 未展示(同上) | 未做 |
| measurement.samples_seen | 这次收到的样本数 | 服务端 | 未展示 | 未做 |
| measurement.duration_s | 测量时长,秒 | 服务端 | 未展示 | 未做 |
| measurement.note | 用户备注,≤ 500 字 | 服务端 | 未展示(弹层 · 测量能填,只存本地) | 未做 |
| event.kind | habit_checkin | reminder | note | medication | supplement | illness | travel | 服务端 | 未展示 | 未做 |
| event.ts | 事件时刻 | 服务端 | 未展示 | 未做 |
| event.attrs | 事件属性,如 {habit} | 服务端 | 未展示(长寿 · 打卡只存本地) | 未做 |
| device.model_code | 型号 somaloop-2 | 服务端 | 未展示 | 未做 |
| device.firmware_version | firmwareHex-dateHex | 服务端 | 未展示(设备弹层的固件版本直接读手环) | 未做 |
| device.capability_state | verifiedReference … unsupported | 服务端 | 未展示(只在上传护照时报上去) | 未做 |
| device.battery_pct | 电量 % | 服务端 | 未展示(首页头部 · 状态行直接读手环) | 未做 |
| device.sdk_version | SDK 版本 | 服务端 | 未展示 | 未做 |
| device.sdk_build_revision | SDK 构建号 | 服务端 | 未展示 | 未做 |
| device.hardware_identity | sha256(小写 mac 去冒号) | 服务端 | 未展示 | 未做 |
四个读取接口(/me、/me/timeline、/me/measurements、/me/events、/devices/{id})一个都没调。样本与测量这两套字段 App 天天在产(上传走的就是这个契约),但从来没读回来过。
手环 SDK · 能力表里的每一项
| 字段 | 含义 / 单位 | 来源 | 展示位置 | 状态 |
|---|---|---|---|---|
| history.dailyActivity | 按天汇总的步数 / 距离 / 热量 / 活动时长 | 手环 SDK | 洞察 · 今天的节奏「活动」行、步数话题;长寿 · 28 天覆盖、按周看 | 已做(只取了步数) |
| history.stepDetail | 分钟级步数明细 | 手环 SDK | 未展示 | 未做(不在同步清单) |
| history.sleep | 睡眠分期码 | 手环 SDK | 未展示 | 未做(不在同步清单) |
| history.heartRate | 连续心率序列 | 手环 SDK | 首页头部 · 设备场景读数;洞察 · 身体信号四格、心率话题、今天的节奏;长寿 · 按周看;弹层 · 依据、指标详情 | 已做(按天取中位数) |
| history.singleHeartRate | 单次心率条目 | 手环 SDK | 未展示 | 未做(不在同步清单) |
| history.hrv | HRV / 疲劳 / 压力估计 | 手环 SDK | 洞察 · 身体信号四格;长寿 · 28 天覆盖、按周看;弹层 · 依据、指标详情 | 已做 |
| history.temperature | 皮肤温度 | 手环 SDK | 洞察 · 身体信号四格、亮点;弹层 · 指标详情 | 已做(当绝对值显示,语义不对) |
| history.bloodOxygen | 血氧 | 手环 SDK | 洞察 · 身体信号四格;弹层 · 指标详情 | 已做 |
| history.pulseInterval | 脉搏间期 PPI | 手环 SDK | 未展示 | 未做(呼吸率要从它推) |
| history.workout | 运动记录 | 手环 SDK | 未展示 | 未做 |
| history.alarms | 闹钟列表 | 手环 SDK | 未展示 | 未做(契约不收,不上传) |
| history.sleepActivity | 睡眠 + 活动量合并记录 | 手环 SDK | 未展示 | 未做 |
| history.sleepDiagnostics | 睡眠诊断 | 手环 SDK | 未展示 | 未做(诊断流不进用户界面) |
| history.systemDiagnostics | 系统诊断 | 手环 SDK | 未展示 | 未做(同上) |
| history.powerDiagnostics | 电源诊断 | 手环 SDK | 未展示 | 未做(同上) |
| measurement.heartRate | 主动测心率 次/分 | 手环 SDK | 弹层 · 测量 | 已做 |
| measurement.bloodOxygen | 主动测血氧 % | 手环 SDK | 弹层 · 测量(含同批返回的 supporting) | 已做 |
| measurement.hrv | 主动测 HRV ms | 手环 SDK | 弹层 · 测量 | 部分 |
| measurement.ecg | 心电 | 手环 SDK | 弹层 · 测量(入口在,灰掉并写明原因) | 受阻,见第 09 节 |
| capability.identity | MAC / 固件 / 设备日期 | 手环 SDK | 设备弹层 · 固件版本;Lab · 手环与固件 | 已做 |
| capability.battery | 电量 % | 手环 SDK | 首页头部 · 状态行;设备弹层 | 已做 |
| DeviceCapabilityMap | 每项能力的五档支持度 | 手环 SDK | 设备弹层 · 可读数据(前 3 项 +「等 N 项」);弹层 · 测量的灰态与原因;Lab · 能调的工具 | 已做 |
| readSetting(...) | 读 clock / personalProfile / stepGoal / autoMeasurement / reminders / alarms / basic 七组 | 手环 SDK | 未展示 | 未做 |
| writeSetting(...) | 写同样七组 | 手环 SDK | 未展示 | 待 SDK 支持 |
| rawCapture.ppgOnly | 只采 PPG | 手环 SDK | 未展示 | 未做 |
| rawCapture.ppgWithMotion | PPG + 加速度联合 | 手环 SDK | 未展示 | 未做 |
| signalStream.ecg | 心电原始波形(0x07 包) | 手环 SDK | 未展示 | 受阻,见第 09 节 |
| signalStream.motion | 加速度原始流(0x33 包) | 手环 SDK | 未展示 | 未做 |
| signalStream.diagnostics | ASCII 诊断流 | 手环 SDK | 未展示 | 未做 |
| longCapture | 24 小时连续采集 | 手环 SDK | 未展示 | 未做 |
| haptics | 触觉节拍 | 手环 SDK | 未展示 | 未做(需求第 6 项) |
同步默认只拉五条流:dailyActivity、heartRate、bloodOxygen、hrv、temperature。睡眠流不在里面——不是「存了原文没解析」,是根本没读。
App 现有的十一个指标(Domain)
| 字段 | 含义 / 单位 | 来源 | 展示位置 | 状态 |
|---|---|---|---|---|
| Domain.recovery | 恢复度 · 分 | 服务端 | 首页 · 三张卡(第一张);洞察 · 恢复话题、亮点;长寿 · 28 天覆盖、最近两周、恢复度按周;弹层 · 指标详情 | 受阻(真机下全是「—」) |
| Domain.hrv | 心率变异性 · ms | 手环 SDK | 洞察 · 身体信号四格、亮点;长寿 · 28 天覆盖、按周看;弹层 · 依据、指标详情 | 已做 |
| Domain.sleep | 睡眠 · 时长 | 服务端 | 首页 · 三张卡(第二张);睡眠 · 昨夜、分期条、这两周;洞察 · 睡眠话题、今天的节奏、亮点;长寿 · 28 天覆盖、按周看;弹层 · 依据、指标详情 | 受阻(睡眠流不在同步清单) |
| DailySnapshot.sleepScore | 睡眠分 · 分 | 服务端 | 睡眠 · 睡眠分面板;首页 · 睡眠卡状态行 | 受阻(无产出方,真机下为「—」) |
| Domain.nightHeart | 夜间心率 · 次/分 | 服务端 | 洞察 · 今天的节奏(静息心率行的副文案,全 App 唯一一处) | 受阻(无产出方) |
| Domain.temperature | 皮肤温度 · °C | 手环 SDK | 洞察 · 身体信号四格、亮点;弹层 · 指标详情 | 已做(应改成相对基线的偏差) |
| Domain.heart | 静息心率 · 次/分 | 手环 SDK | 首页头部 · 设备场景读数;洞察 · 身体信号四格、心率话题、今天的节奏、亮点;长寿 · 按周看;弹层 · 依据、指标详情 | 已做 |
| Domain.oxygen | 血氧 · % | 手环 SDK | 洞察 · 身体信号四格、亮点;弹层 · 指标详情 | 已做 |
| Domain.steps | 步数 · 步 | 手环 SDK | 首页 · 三张卡(分数为空时顶上);洞察 · 步数话题、今天的节奏、亮点;长寿 · 28 天覆盖、按周看;弹层 · 指标详情 | 已做 |
| Domain.distance | 距离 · 公里,按步数 × 0.72 m 折算 | 本地折算 | 洞察 · 今天的节奏「活动」行 | 已做(没有独立卡片,也没有详情入口) |
| Domain.calories | 活动热量 · 千卡,按每步 0.04 kcal 折算 | 本地折算 | 洞察 · 步数话题的结论段(一句话) | 已做(同上) |
| Domain.load | 身体负荷 · 无量纲 | 服务端 | 首页 · 三张卡(第三张);洞察 · 亮点;弹层 · 指标详情 | 受阻(真机下为「—」) |
真实数据下本地库只写得出五项:心率、血氧、HRV、皮温、步数(按天取中位数,步数取当日累计)。恢复度、睡眠时长、睡眠分、夜间心率、负荷没有任何产出方——手环没读,服务端没接,界面上一律是「—」。带 dev-data 启动时才由模拟值顶上。
本地与用户输入 · 运行时状态
| 字段 | 含义 / 单位 | 来源 | 展示位置 | 状态 |
|---|---|---|---|---|
| store.name | 昵称 | 本地 | 首页头部 · 头像首字、问候语;弹层 · 我与设置 | 已做 |
| store.sleepTarget | 睡眠目标 · 小时,6–10 步长 0.5 | 本地 | 睡眠 · 昨夜差额、这两周平均与睡眠债;洞察 · 睡眠话题;弹层 · 我与设置 | 已做(服务端有 preferences.sleep_target_h,未同步) |
| store.reminders | 每日结论提醒开关 | 本地 | 弹层 · 我与设置 | 已做(未同步) |
| store.quietHours | 夜间安静时段开关 | 本地 | 弹层 · 我与设置 | 已做(未同步) |
| store.wearPosition | 佩戴位置 手腕 / 上臂 | 本地 | 首页头部 · 设备场景上方的切换 | 已做(服务端无对应字段) |
| store.habits | 打卡集合 | 本地 | 长寿 · 今天的打卡 | 已做(应走 POST /me/events) |
| store.insightHelpful | 有用 / 没用 | 本地 | 洞察页尾、长寿页尾 | 已做(只存本地) |
| store.records | 手动测量记录:类型 / 读数 / 备注 | 本地 | 未展示 | 受阻(唯一列出它的「最近测量」在已下线的身体页里) |
| ReminderView · time | 入睡提醒时刻 | 本地 | 弹层 · 睡前提醒 | 部分(只活在这一屏的状态里,保存后只往打卡集合插一条字样) |
| store.connected | 连接状态 | 手环 SDK | 首页头部 · 状态行;设备弹层 | 已做 |
| store.battery | 电量 % | 手环 SDK | 首页头部 · 状态行;设备弹层 | 已做 |
| store.syncing / store.syncState | 同步中 / 同步结果原话 | 本地 | 首页头部 · 状态行同步钮;设备弹层 · 同步状态 | 已做 |
| store.signedIn | 登录状态 | 服务端 | 弹层 · 我与设置、设备 | 已做 |
| store.deviceBound | 手环是否绑到账号 | 服务端 | 设备弹层 · 账号 | 已做 |
| store.uploadState | 上传回执 | 服务端 | 设备弹层 | 已做 |
| identity.firmwareDisplay | 固件版本 | 手环 SDK | 设备弹层 · 固件版本;Lab · 手环与固件 | 已做 |
| devices.historyStreams() | 这只手环能读哪几条流 | 手环 SDK | 设备弹层 · 可读数据 | 已做 |
| devices.availableMeasurements | 可测项数 | 手环 SDK | Lab · 能调的工具(measure 行) | 已做 |
| rawRecordCount() | 本地原始记录条数 | 本地 | Lab · 本地存了什么 | 已做 |
| pendingUploadCount() | 等待上传条数 | 本地 | Lab · 本地存了什么 | 已做 |
| daysWithSamples().count | 有记录的天数 | 本地 | Lab · 本地存了什么 | 已做 |
| AgentTool.all | measure / daily / series / raw / sync 五个工具及可用性 | 本地 + SDK | Lab · 能调的工具 | 已做 |
| CSV 导出 | date,metric,value,90 天全部指标 | 本地 | Lab · 导出 CSV + 系统分享 | 已做 |
| store.selectedDate | 选中日期,范围 90 天 | 本地 | 洞察 / 长寿 / 睡眠 · 日期切换条 | 已做 |
| availableSeries(_:days:) | 本地日序列,缺的天不补点 | 本地 | 所有趋势图、区间条、覆盖计数的数据源 | 已做 |
| ConsentText.version | 同意文本版本号 | 本地 | 弹层 · 登录页脚 | 已做 |
| HealthStore.usesRealData | 这次运行是不是演示数据 | 本地 | 弹层 · 依据页末句 | 已做 |
| Repository · 模拟播种 | 90 天模拟日报 | 本地 | 未展示(只在带 dev-data 时顶替空值) | 已做 |
三个最值得先补的缺口。① 服务端日报 38 个字段一个没读,其中四个睡眠分项与七个恢复分项是现成的、算好的、专门给「依据」页用的,界面上却只摆了一句口径说明。② 睡眠流压根不在同步清单里(synchronise() 默认只拉五条),所以睡眠时长、分期、睡眠分、夜间心率、负荷、恢复度在真机上全是「—」;睡眠页的分期条只有带 dev-data 启动才有内容。③ 洞察页的「全部指标」入口按下去打开的是入睡提醒——all-metrics 这个弹层 id 在路由里没有分支,落到了默认分支;连带 MetricListRow 与「最近测量」列表当前没有任何调用点,距离、活动热量、夜间心率因此没有详情入口。
09拿不到的东西
不是没做,是当前拿不到。每条写清卡在哪。
| 想要的 | 卡在哪 | 解法 |
|---|---|---|
| ECG 真实波形 | 设备支持,V8 源码里也有(0x28 子码 04),但 L1 SDK 把能力表编进了二进制,调用被拦 | V8 直连包(见下节)或厂商放开 |
| 主动测量(心率/血氧/HRV) | 同上。dev-console 用同样的字节在同样的硬件上跑通过 | V8 直连包 |
| 手环震动认人 | 两边 SDK 都没有实现。L1 的 hapticRhythm 绑死单一参考 MAC(f6c6e1eed41b / 固件 00000709-260519),我们的手环是 f8ed4c8d1816 / 00000808-260604,两项都不匹配 | 配对页里的震动按钮已移除——按下去只会报错。认人改用信号强弱(把想绑的那只贴近手机重新搜,列表按 RSSI 排序)。将来 V8 直连包可以试发 97 36 |
| 睡眠区间、多段睡眠、午睡 | 睡眠流已加入同步清单,但只存了原文,未解析成区间 | 解析 sleep 流,走 soma.interval/v1 |
| 佩戴检测(「已识别 · 贴合良好」) | SDK 无此能力 | 已降级成用户自选的设置 |
| 连续 ECG 流 / 实时心率 | 手环按间隔采样,无实时流 | 读数标「静息心率」,不标「此刻」 |
| 呼吸率 | 无此字段 | 可从 PPI 的呼吸性窦性心律不齐推算,未做 |
| 校时、设置写入、设备闹钟、固件升级 | SDK 未开放 | 待厂商 |
| 人群参考范围 | 没有人群数据 | 一律用个人基线;服务端的冷启动先验也只是起点,14 晚后完全按个人基线 |
11V8 直连包
L1 把一批能力关在闸后面,而 dev-console 用同样的字节在同样的硬件上跑通过——闸在 SDK 里,不在设备里。这个包按厂商源码自己拼包直连 BLE 发,等 L1 放开后整体并回去。
已经建好的:纯字节层
SomaLoop/DeviceV8/V8Protocol.swift,不碰蓝牙,9 个单测全部对着厂商已知样本断言。
| 项 | 值 | 出处 |
|---|---|---|
| 帧长 | 16 字节,不分包 | 三条已知指令反推 |
| 校验 | 末字节 = 前 15 字节之和 & 0xFF | 三条样本全部吻合 |
| 服务 / 写 / 通知 | FFF0 / FFF6 / FFF7 | V8 iOS demo V8SdkAdapter.m |
| 测量 | 0x28 | byte1 类型、byte2 开关、byte4–5 秒数小端、心电另置 byte6 |
| 触觉 | 97 36 01 / 97 36 00 | L1 protocol/haptics.json 原文 |
一处两份厂商 SDK 互相矛盾:HRV 的子码 Android AutoTestMode 是 0x01,iOS MeasurementDataType_V8 写的是 4(和心电撞号)。按 Android 那份走,因为 dev-console 跑通的是那一套。真机验到哪种就改哪种。
两处要更正的认知
V8 原生 SDK 里没有震动实现。iOS 头文件 37 个方法没有一个是震动;Android 的 BleSDK.java 也没有对应构造器。MotorVibration_V8 = 14 与 SetMotorVibrationWithTimes = "14" 都只是声明了但没实现的命令类型,那个 14 是 API 枚举序号不是 opcode——Android 那份里 0x14 实际是 Temperature_3NTC。所以「V8 有 L1 没有的震动能力」这个前提不成立;唯一已知的震动机制是 L1 那条 97 36(靠主机反复开关提醒状态凑节奏,且实测时序验收失败过)。
还没写的
- BLE 传输层(扫描 / 连接 / 写 FFF6 / 订阅 FFF7 / 分包重组)
- 回包解析:测量结果、心电波形
- 与
DeviceLink的接线:L1 拒绝时自动退到直连 - 震动:字节已备好,但要先在实机上验证设备收不收——验通了再往界面上放按钮
12变更记录
| 日期 | 变更 |
|---|---|
| 09-28 | 接上 GET /me/days。38 个日报字段落库(daily_report 表存原文,daily 表存给界面的数,source=server)。sync() 里先上传再拉取——反过来服务端还没收到今天的数据。每次整段重拉 92 天不做增量,因为服务端是读时现算的。 |
| 09-28 | 三个分数为 null 时不写库。null 是「算不出来」,写 0 会被读成「恢复度为零」。有单测钉着。 |
| 09-28 | 三张卡接入稳定度四态;睡眠分四个分项列出来了;顶栏显示 last_sync_at;皮温改成「皮温偏差」。 |
| 09-28 | 补上三条上报:测量(成功失败都报)、打卡(POST /me/events)、偏好(PATCH /me,连按合并 600ms)。 |
| 09-28 | 修:JSONEncoder 默认省略 nil 而非输出 null,而 value 在契约的 required 里——测量失败会吃 400 invalid_schema。改成手写 encode(to:)。 |
| 09-28 | 入门硬闸:不登录 + 不绑手环看不到任何数值。五步(欢迎 → 账号 → 出生年/性别 → 配对 → 首次同步),断点续走。配对前检查蓝牙状态并区分「关着 / 没权限 / 不支持」。 |
| 09-28 | 修两个死路:.sleep 不在同步清单里(睡眠数据压根没读);all-metrics 弹层没有分支,点开的是「睡前提醒」。 |
| 09-28 | 配对页移除「让这只震一下」按钮与设备层的 identify 接线——两边 SDK 都没有实现,留着是死代码加一颗必然报错的按钮。认人改用信号强弱引导。 |
| 09-27 | Basic 界面按 Wise 重做;栏目收成 洞察/长寿/睡眠/Lab;Lab 重点改成 agent 接入。 |
SomaLoop L1 SDK 接入问题汇总
iOS App 接 L1 SDK 期间攒下来的问题,按「挡住了什么」排序。每一条都标了证据出处——SDK 自己的文档、protocol.json、两份原厂 V8 SDK 源码,或我们的真机记录。写这份不是为了记账,是因为其中几条现在正挡着 App 的功能,需要 SDK 方定夺。
S1一页看完
九条。前三条正在挡功能,中间三条是一致性问题,后三条是语义空白。
- 测量拿不到。手上这台固件
00000808的设备,measure()稳定返回unverifiedCapability。而 develop-console 用同样的字节在同样的硬件上跑通过——闸在 SDK 里,不在设备里。 - 触觉绑死在一个 MAC 上。
protocol.json原文:haptics requires reference MAC,那个 MAC 是f6c6e1eed41b。别的设备一律发不出。 - 而且触觉在 V8 层面根本没有实现。两份原厂 SDK(iOS 头文件、Android
BleSDK.java)都没有任何发送振动的函数。 - SDK 自报版本是
0.1.4-beta,不是0.1.4。仓库 tag、VERSION文件、xcframework 的 plist 全都写0.1.4,只有运行时常量带-beta。 - 验收记录是另一个构建产出的。
validation-status.json里buildRevision是c88d990b…、sdkVersion是0.1.0-beta;而交付的二进制自报934adc2f…/0.1.4-beta。两者对不上,且那份记录"finished": false。 - 两份原厂 SDK 在 HRV 子码上互相矛盾。Android 是 HRV=
0x01,iOS 头文件是 HRV=4,而4在 Android 那份里是心电。 - 手动测量有个没写进 SDK 文档的前置条件。原厂 iOS 文档:「只有在实时计步处于开启状态下,手动测量才会有数据返回」。
- 没有发裸帧的公开入口。
ProtocolCodec.command能拼字节,却没有对应的发送 API;而文档又明确禁止绕过去直接写 transport。 - 好几个字段的单位没有声明。运动记录的时长、心电/PPG/ACC 的采样率与标定,都只能原值保留——任何换算都是编的。
S2能力闸挡住了测量
现象
手上这台设备(JCV8B,固件 00000808-260604)调 measure(.heartRate) 稳定返回 unverifiedCapability。心率、血氧、HRV 三种都一样。
为什么
《能力与迁移.md》说明了判定规则:
相同参考设备及固件记 verifiedReference;另一台设备报告同样固件与日期只记 compatibleCandidate。……0088 以独立能力矩阵为准,不能继承全部能力。
也就是说,闸不是按「设备能不能做」开的,是按「厂商验没验过这一台」开的。这个设计本身没问题——unsupported 必须有证据,不因一次没响应就设置,这条规矩是对的。问题在于结果是一台功能正常的设备被判为不可用,而 App 侧没有任何办法推翻。
反证
develop-console 的 Python 实现,用同一批字节(0x28 子码 02/03/01)在同一台硬件上拿到过结果。原厂 V8 SDK 的源码里也没有任何固件门禁——grep firmware、grep capability 在 BleSDK.java 里一处都没有。所以设备是支持的。
我们的处理
写了一个 V8Protocol 纯字节层,按原厂 SDK 源码把包自己拼出来。没有绕过 SomaLoopClient 去直接写 AppleBLETransport——文档明确要求不要那样做,而且会和客户端自己的命令串行化打架。所以这条路目前是停在「字节拼好了,但没有合法的发送通道」。
S3触觉:绑死在一台设备上,而且没有实现
绑死在参考 MAC
protocol/protocol.json 的兼容性 profile 里写得很直白:
| 字段 | 值 |
|---|---|
| id | somaloop-fw-00000709-260519 |
| referenceDeviceMAC | f6c6e1eed41b |
| scope | Existing 0079 behavior retained; haptics requires reference MAC. |
所以触觉只在那一台特定的手环上能发。这对「用振动认出是哪一只手环」这个需求是致命的——恰恰是有多只手环时才需要它。
更根本的问题:V8 层面没有振动
我一开始以为 iOS 头文件里的 MotorVibration_V8 = 14 是个操作码,这是错的。它是回调数据类型枚举的一个成员(一个索引),不是可发送的命令;0x14 在协议里是 Temperature_3NTC。
核对两份原厂 SDK:
- iOS
BleSDK_Header_V8.h:全文搜ibrat,只有MotorVibration_V8 = 14这一个枚举成员,没有任何发送函数。 - Android
BleSDK.java:搜ibrat,零命中。
L1 的 97 36 01 / 97 36 00(内部提醒状态开关)是 L1 自己加的,V8 原厂没有这个能力。所以「不绑定就发振动」这条路走不通,我们已经把配对流程里的振动去掉了。
S4版本号与构建证据对不上
版本号差一个后缀
| 来源 | 值 |
|---|---|
| git tag | v0.1.4 |
VERSION 文件 | 0.1.4 |
xcframework 内层 CFBundleShortVersionString | 0.1.4 |
| GitHub Release 标题 | SomaLoop L1 SDK 0.1.4 |
运行时 SomaLoop.version | 0.1.4-beta |
这条本身不算严重,但它坑了我们一次:App 里三个地方把 sdkVersion 写死成 "0.1.4" 上报给服务端,一直是错的,而且不会有任何编译错误或运行时报错。现在改成读 SomaLoop.version,并把 buildRevision 一并上报。
验收记录来自另一个构建
validation-status.json | 交付的二进制 | |
|---|---|---|
| buildRevision | c88d990b…5046 | 934adc2f…55e1 |
| sdkVersion | 0.1.0-beta | 0.1.4-beta |
并且那份记录自己写着 "finished": false、failedCases: ["run_not_finished"]。45 个用例里 36 passed、7 gate_verified、1 observed、1 empty_complete。unacceptedCapabilities 列了 6 项,其中三项正好是我们要用的:manual_hrv_numeric_result、continuous_ecg、public_settings_write_api。
这不是说 SDK 有问题,是说随包交付的验收证据对应不上随包交付的二进制,我们没法用它判断手上这份能做什么。
S5两份原厂 SDK 互相矛盾
HRV 子码
Android BleSDK.SetDeviceMeasurementWithType 是显式 switch(不是枚举序号):
| 测量 | Android BleSDK.java | iOS MeasurementDataType_V8 |
|---|---|---|
| 心率 | 0x02 | 2 |
| 血氧 | 0x03 | 3 |
| HRV | 0x01 | 4 |
| 心电 | 0x04(另置 byte6=1) | 没有这一项 |
HRV 对不上,而且 iOS 给 HRV 的那个 4 正好是 Android 给心电的值。我们按 Android 那份实现,因为 develop-console 跑通的是那一套;iOS 那份大概率只是没把心电列进去,顺手把号占了。这一点需要在真机上验一次,确认哪一份是对的。
时长的单位
iOS 原厂文档写:「measurementTime : 测量的时间,单位是秒,默认是 30 秒」,demo 里传 30。而 Android 的 ECG demo 传的是 (50 * 1000).toLong(),进的是同一个 byte4–5 字段。如果那也是秒,等于 13.9 小时。两边至少有一边是错的。
一个没写进 SDK 文档的前置条件
原厂 iOS 文档原文:「注意 : 只有在实时计步处于开启状态下,手动测量才会有数据返回」
也就是说要先发 RealTimeDataWithType:1 打开实时通道,0x28 才会吐数据。L1 SDK 的文档里没有提这条。如果 measure() 内部没有替调用方做这一步,那可能正是某些「发了命令但没有回包」的原因。
S6接口缺口
没有发裸帧的公开入口
ProtocolCodec.command 是公开的、能拼出合法字节,但 SomaLoopClient 没有对应的发送 API。同时文档又要求「不要绕过后直接调用底层 transport 写入」。结果是:知道该发什么、也能拼出来,但没有合法的路把它发出去。
这一条挡住的不只是我们的 workaround。心电流也卡在同一个地方,见下。
研究流没有 duration 参数
develop-console 的实现(advanced.py:298-314)说明心电要两步:
write(frame(0x07, 1))—— 开流request(frame(0x28, 4, 1, 0, duration&255, duration>>8, 1))—— 启动测量,应答判据len == 16 && b[1] == 4
而 iOS 的 SomaLoopResearch.start(_:) 只有一个 stream 参数,没有 duration,接口是不透明的:无法判断它内部是只发了第 1 步,还是两步都发了。
- 如果两步都发了 —— 现在这样就是对的;
- 如果只开了流 —— 设备不会吐波形,而适配器没有办法自己补上第 2 步(见上一条)。
这一点我们还没在真机上验证过。
settings 写入未开放
《能力与迁移.md》:「修改时间、个人资料、自动测量、提醒、闹钟等 | 公开 API 未开放 | 6 个具体字段已在内部完成写入、回读、恢复;尚未提升为公开 setter 契约,其余字段未验证;稳定返回 unverifiedCapability」。已知、可接受,记在这里是为了清单完整。
S7语义没声明的字段
这一类不算 bug,是「固件没说,所以谁也不能编」。记下来是因为它们直接决定界面上能写什么。
| 字段 | 缺什么 | 后果 |
|---|---|---|
| 运动记录 duration | 一个 u16,没声明单位 | 不能在界面上写「跑了 32 分钟」 |
| ECG / PPG / ACC | 没有采样率、没有标定系数 | 只能保留原始计数,不能换算 mV、不能给逐点时间戳、不能下任何医学结论 |
| 睡眠 + 活动量(0x6B) | 每字节代表 2 分钟,但强度单位未定义 | 只能当相对值用 |
| HRV | 「09 不暴露独立 HRV 指标」 | 手动 HRV 测量的数值待验收 |
| 闹钟 weekMask | 比较设备曾返回 255 | 原值保留,不归一化 |
| 设备日历 | 没有时区 | 不能自动转 UTC——这一条 SDK 文档写明了,是对的 |
S8我们为此做了什么
固件证据表
厂商把一条能力标成 experimental,含义是「厂商没验证」,不是「设备不支持」。实测发现固件 00000808 上,被标成实验的血氧一次能完整读回 714 条、HRV 600 条——数据是真的,只是不在厂商的验收清单里。
但直接把全局实验开关打开等于对所有能力一律放行,那就退回到「猜」了。所以我们逐条记:哪个固件、哪条流、什么时候、在哪台设备上、读到了什么。没有证据的一律不放行。
一个适配器文件
全 App 只有 SomaLoopL1Link.swift 可以 import SomaLoopSDK,有一条扫描源码的边界测试钉着。厂商枚举不往外跑,所以上面这些问题将来怎么变,影响面都只有那一个文件。
V8 字节层
按原厂 SDK 源码把包拼出来,纯字节、不碰蓝牙、可单测。等 L1 放开之后整体并回去,对外接口不变。目前缺的是合法的发送通道(见 S6)。
S9给 SDK 方的清单
按「挡住了什么」排序,不按修起来容易排。
| # | 请求 | 挡住了什么 |
|---|---|---|
| 1 | 让 compatibleCandidate 设备能在调用方显式承担风险的前提下执行 measure()(例如一个 allowUnverified 参数),而不是一律拒绝 | 心率 / 血氧 / HRV 主动测量,以及依赖它的整个测量入口 |
| 2 | 触觉解除参考 MAC 绑定;若 V8 硬件本就没有振动能力,请在文档里写明,我们好换方案 | 多手环场景下认出是哪一只 |
| 3 | 开放一个发送裸帧的公开入口(走客户端的串行化,不绕 transport) | 所有 workaround,以及心电第 2 步 |
| 4 | SomaLoopResearch.start(_:) 补 duration 参数;或在文档里说明内部是否已发 0x28 sub4 | 心电波形 |
| 5 | 确认手动测量是否需要先开实时计步;若需要,请在 measure() 内部处理或在文档写明 | 可能是部分「无回包」的真因 |
| 6 | 确认 HRV 子码到底是 0x01 还是 0x04 | HRV 测量 |
| 7 | 重出一份与交付二进制对应的 validation-status.json(当前那份 buildRevision 与 sdkVersion 都对不上,且 finished: false) | 我们无法判断手上这份能做什么 |
| 8 | 统一版本号:运行时常量 0.1.4-beta vs tag / VERSION / plist 的 0.1.4 | 上报给服务端的设备护照 |
| 9 | 声明运动记录 duration 的单位 | 运动记录没法显示 |
需要我们自己先做的事:第 4、5、6 条都需要真机验证才能给 SDK 方确定的结论,不该直接当成缺陷提过去。我们这边的下一步是把这三条在设备上验一遍,带着实测结果再谈。