Skip to content
Get Started

① Collect

Pulse Provider System

How Pulse plugs data sources in: the platform/provider split, the BasePullProvider contract, link types, scheduled pulls, and normalisation to StandardPulseData.

Health data reaches Mirobody through Pulse, which is split in two layers. A platform owns a family of sources plus the machinery they share; a provider is one concrete source inside a platform. Two platforms are registered at startup: theta is the pluggable one and owns every provider on disk, and apple is the built-in on-device batch importer, which accepts no plugins.

A theta provider subclasses BasePullProvider. The base class already implements credential storage, unlinking, timezone resolution and the per-user pull loop, so a subclass only supplies what is specific to its source.

Only two methods must be implemented: save_raw_data_to_db (keep the raw payload) and is_data_already_processed (idempotency). The rest have working defaults, or raise a clear error naming the method you were supposed to write.

info returns a ProviderInfo. It is pure metadata: listing every provider costs no network call and no credentials, which is what makes GET /api/v1/pulse/providers cheap.

ProviderInfo fieldMeaning
slugUnique identifier, e.g. theta_garmin.
name · description · logoWhat the client shows.
supported · statusWhether the source is offered, and its availability.
auth_typeA LinkType, which decides the connection code path.
platformThe platform the provider belongs to.
connect_info_fieldsThe form to collect, when the source needs credentials rather than OAuth.
connect_info_fields is how a provider declares a form instead of hard-coding one in the UI. Each entry is a ConnectInfoField with field_name, field_type (string / number / select / password), required, label, and optional placeholder, default_value, options. The PostgreSQL provider uses five of them to ask for host, port, database, username and password.

status is one of available · connected · disconnected · reconnect · error · maintenance. A provider declares available; the router overwrites it per user from what is actually linked.

auth_type decides which flow a connection takes. The enum carries eleven values, but only a few are live in the shipped providers:

LinkTypeFlowUsed by
OAUTH1Browser redirect, then GET /api/v1/pulse/{platform}/{provider}/callback with oauth_token + oauth_verifiertheta_garmin
OAUTH2Browser redirect, then the same callback with code + statetheta_whoop, theta_oura
PASSWORDDirect link with username + password — no browser
CUSTOMIZEDDirect link with a connect_info object matching connect_info_fieldstheta_pgsql
NONENo connection step at allapple_health

A PASSWORD or CUSTOMIZED provider gets validated and stored in one request, while an OAuth provider returns a link_web_url first and completes on the callback. Both end in the same place: credentials saved encrypted.

A source that has to be polled gets a scheduled task — the provider declares whether it wants one. The cadence is per-slug, and each task takes a distributed lock, so several server instances can run the same schedule without pulling twice:

SlugExecution intervalLock duration
theta_oura5 minutes4 minutes
theta_whoop24 hours23.5 hours
theta_renpho24 hours23.5 hours
theta_vital6 hours5.5 hours
theta_cgm1 hour30 minutes
anything else1 hour30 minutes

When a task fires, the provider loads every linked user’s credentials, fetches from the vendor per user, skips whatever it recognises as already processed, and hands the rest to the write path.

Whether a payload arrived by webhook or by scheduled pull, every provider is bound by the same constraint: convert your source’s shape into StandardPulseData.

Identity and timezone are resolved before formatting, so the format_data_v2 you write is pure: it maps fields and does no I/O. Each entry of healthData is a StandardPulseRecord:

StandardPulseRecord fieldMeaning
sourceWhere the reading came from, e.g. vital.garmin.
typeThe registered indicator name, not the vendor’s field name.
timestampMilliseconds. startTime / endTime carry a period instead of a point.
value · unitThe reading itself, and the unit as the source reported it.
timezoneDefaults to UTC.
source_id · task_idOptional provenance, used for idempotency and tracing.
type must be a registered indicator name rather than the source’s own field name — that is what makes readings from a Garmin watch and an Oura ring comparable. See Health Indicators for the registry, and Data Flow for what happens to the records afterwards.

Providers are loaded from disk at startup, so adding one is adding a directory — no registry edit, no rebuild. The directories scanned come from the PROVIDER_DIRS config key, which ships as:

config.yaml
PROVIDER_DIRS:
- mirobody/pulse/providers
- providers

Inside each directory the loader matches mirobody_*/provider_*.py, imports each match, looks for a class that subclasses BasePullProvider, then calls create_provider(config) on it and registers whatever comes back.

Four rules fall out of that, and breaking any one of them makes a provider silently absent:

Directory name

mirobody_<slug>/ — the glob only matches this prefix. Keep your own providers in the root providers/ directory so upgrades don’t touch them.

File name

provider_<something>.py — a differently named module in the same directory is never imported.

Class name

A name ending in Provider, subclassing BasePullProvider. The first match in the module wins.

Factory answer

create_provider(config) returning None is the supported way to stay disabled — that is how a provider with no credentials configured drops out.

The providers that ship live in mirobody/pulse/providers/.