跳转到内容
快速开始

① 采集

Pulse Provider 体系

Pulse 如何插接数据源:platform/provider 两层结构、BasePullProvider 契约、连接类型、定时拉取,以及归一到 StandardPulseData。

健康数据经 Pulse 进入 Mirobody,它分成两层。platform 负责一类数据源以及它们共用的机制;provider 是这一类里的某个具体数据源。启动时注册两个 platform:theta 是可插拔的那个,磁盘上的 provider 都挂在它下面;apple 是内置的端上批量样本导入通道,不接受插件。

一个 theta provider 继承 BasePullProvider。基类已经实现了凭据存储、解绑、时区解析与按用户拉取的循环,子类只补上本数据源特有的部分。

必须实现的只有两个方法:save_raw_data_to_db(把原始负载留档)与 is_data_already_processed(幂等判定)。其余要么有可用的默认实现,要么在缺失时抛出一个点名说明「你该实现哪个方法」的异常。

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_namefield_typestring / number / select / password)、requiredlabel,以及可选的 placeholderdefault_valueoptions。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}/callbackoauth_token + oauth_verifiertheta_garmin
OAUTH2浏览器跳转,随后同一个 callback 带 code + statetheta_whooptheta_oura
PASSWORD直接连接,提交 username + password,不经浏览器
CUSTOMIZED直接连接,提交与 connect_info_fields 对应的 connect_info 对象theta_pgsql
NONE完全没有连接步骤apple_health

PASSWORDCUSTOMIZED 的 provider 一次请求就完成校验并落库;OAuth 的 provider 先返回一个 link_web_url,在 callback 里才算连成。两条路的终点相同:凭据加密保存。

需要轮询的数据源会获得一个定时任务 —— provider 自己声明要不要。节奏按 slug 配置,每个任务还会取一把分布式锁,因此多个服务实例运行同一份排程也不会重复拉取:

Slug执行间隔锁时长
theta_oura5 分钟4 分钟
theta_whoop24 小时23.5 小时
theta_renpho24 小时23.5 小时
theta_vital6 小时5.5 小时
theta_cgm1 小时30 分钟
其它一律1 小时30 分钟

任务触发时,provider 取出所有已连接用户的凭据,逐个用户向厂商取数,丢掉判定为重复的部分,剩下的交给写入。

不管数据是 webhook 送来的还是定时拉来的,每个 provider 都受同一条约束:把自家数据源的形状转成 StandardPulseData

身份与时区在格式化之前就已解析好,因此你写的 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 在启动时从磁盘加载,所以「加一个 provider」就是「加一个目录」,不用改注册表,也不用重新编译。扫描的目录由配置键 PROVIDER_DIRS 指定,默认值是:

config.yaml
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/