① 采集
Pulse Provider 体系
Pulse 如何插接数据源:platform/provider 两层结构、BasePullProvider 契约、连接类型、定时拉取,以及归一到 StandardPulseData。
Platform 与 provider
Section titled “Platform 与 provider”健康数据经 Pulse 进入 Mirobody,它分成两层。platform 负责一类数据源以及它们共用的机制;provider 是这一类里的某个具体数据源。启动时注册两个 platform:theta 是可插拔的那个,磁盘上的 provider 都挂在它下面;apple 是内置的端上批量样本导入通道,不接受插件。
/api/v1/pulse/* 接口面 BasePullProvider 子类 —— 一个数据源一个目录,启动时加载 Provider 基类
Section titled “Provider 基类”一个 theta provider 继承 BasePullProvider。基类已经实现了凭据存储、解绑、时区解析与按用户拉取的循环,子类只补上本数据源特有的部分。
必须实现的只有两个方法:save_raw_data_to_db(把原始负载留档)与 is_data_already_processed(幂等判定)。其余要么有可用的默认实现,要么在缺失时抛出一个点名说明「你该实现哪个方法」的异常。
Provider 元数据
Section titled “Provider 元数据”info 返回一个 ProviderInfo。它是纯元数据:列出全部 provider 既不需要发网络请求也不需要凭据,GET /api/v1/pulse/providers 因此很便宜。
ProviderInfo 字段 | 含义 |
|---|---|
slug | 唯一标识,例如 theta_garmin。 |
name · description · logo | 客户端展示用。 |
supported · status | 是否提供该数据源,以及它的可用状态。 |
auth_type | 一个 LinkType,决定连接走哪条流程。 |
platform | 该 provider 所属的平台。 |
connect_info_fields | 当数据源需要凭据而不是 OAuth 时,要收集的表单。 |
connect_info_fields 让 provider 自己声明一张表单,而不是把表单硬编码进前端。每一项含 field_name、field_type(string / number / select / password)、required、label,以及可选的 placeholder、default_value、options。PostgreSQL provider 用五个字段索取 host、port、database、用户名与密码。
status 的取值是 available · connected · disconnected · reconnect · error · maintenance。provider 自己声明 available;路由再按该用户实际连了什么把它覆盖掉。
auth_type 决定连接走哪条流程。枚举里有十一个值,已发布的 provider 只用到几个:
LinkType | 流程 | 使用者 |
|---|---|---|
OAUTH1 | 浏览器跳转,随后 GET /api/v1/pulse/{platform}/{provider}/callback 带 oauth_token + oauth_verifier | theta_garmin |
OAUTH2 | 浏览器跳转,随后同一个 callback 带 code + state | theta_whoop、theta_oura |
PASSWORD | 直接连接,提交 username + password,不经浏览器 | — |
CUSTOMIZED | 直接连接,提交与 connect_info_fields 对应的 connect_info 对象 | theta_pgsql |
NONE | 完全没有连接步骤 | apple_health |
PASSWORD 与 CUSTOMIZED 的 provider 一次请求就完成校验并落库;OAuth 的 provider 先返回一个 link_web_url,在 callback 里才算连成。两条路的终点相同:凭据加密保存。
需要轮询的数据源会获得一个定时任务 —— provider 自己声明要不要。节奏按 slug 配置,每个任务还会取一把分布式锁,因此多个服务实例运行同一份排程也不会重复拉取:
| Slug | 执行间隔 | 锁时长 |
|---|---|---|
theta_oura | 5 分钟 | 4 分钟 |
theta_whoop | 24 小时 | 23.5 小时 |
theta_renpho | 24 小时 | 23.5 小时 |
theta_vital | 6 小时 | 5.5 小时 |
theta_cgm | 1 小时 | 30 分钟 |
| 其它一律 | 1 小时 | 30 分钟 |
任务触发时,provider 取出所有已连接用户的凭据,逐个用户向厂商取数,丢掉判定为重复的部分,剩下的交给写入。
归一到 StandardPulseData
Section titled “归一到 StandardPulseData”不管数据是 webhook 送来的还是定时拉来的,每个 provider 都受同一条约束:把自家数据源的形状转成 StandardPulseData。
save_raw_data_to_db format_data_v2 身份与时区在格式化之前就已解析好,因此你写的 format_data_v2 是纯的:只做字段映射,不做 I/O。healthData 的每一项是一个 StandardPulseRecord:
StandardPulseRecord 字段 | 含义 |
|---|---|
source | 读数的来源,例如 vital.garmin。 |
type | 已登记的指标名,不是厂商自己的字段名。 |
timestamp | 毫秒。需要表示时段时用 startTime / endTime。 |
value · unit | 读数本身,以及来源上报的单位。 |
timezone | 默认 UTC。 |
source_id · task_id | 可选的来源信息,用于幂等与追踪。 |
type 必须填已登记的指标名,而不是数据源自己的字段名。Garmin 手表和 Oura 戒指的读数正是因此才能放在一起比较。registry 见健康指标,记录之后的去向见数据流。
Provider 的发现方式
Section titled “Provider 的发现方式”Provider 在启动时从磁盘加载,所以「加一个 provider」就是「加一个目录」,不用改注册表,也不用重新编译。扫描的目录由配置键 PROVIDER_DIRS 指定,默认值是:
PROVIDER_DIRS: - mirobody/pulse/providers - providers在每个目录里,加载器按 mirobody_*/provider_*.py 匹配文件,导入后寻找继承了 BasePullProvider 的类,再调用它的 create_provider(config),把返回值注册进来。
由此得出四条规矩,违反任何一条都会让 provider 悄无声息地消失:
mirobody_<slug>/:匹配只认这个前缀。自己的 provider 放在根目录 providers/ 下,升级时就不会被动到。
provider_<something>.py:同一目录下换个名字的模块永远不会被导入。
以 Provider 结尾,并继承 BasePullProvider。模块里第一个命中的类胜出。
create_provider(config) 返回 None 是官方支持的「保持禁用」方式:没配凭据的 provider 就是这样退场的。
真正随仓库发布的数据源,以及各自需要什么
配置凭据、连接账号、看着数据进来
完整走读一份实现
照着这套契约写你自己的
随包发布的 provider 都在 mirobody/pulse/providers/。