Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Open Wearables Integration

JHE can ingest observations from an Open Wearables (OW) backend.

This page is the single onboarding doc for the OW PoC: setup, configuration, end-to-end test.

WhatWhere
OAuth proxy + user creation viewscore/views/ow.py
Patient-facing launch / complete pagescore/templates/clients/ow/
Polling command (every 15 min)core/management/commands/ow_poll.py
Raw S3 reader (raw mode only)core/services/ow_ingest.py
Cron sidecar scheduledeploy/crontab
Teststests/backend/test_ow_poll.py

Architecture

Patient browser  ──▶  /ow/?code=<invitation>     (launch.html)
                       │
                       ├─ POST /api/v1/ow/users           ──▶ OW: create user
                       └─ GET  /api/v1/ow/oauth/oura/...  ──▶ OW: provider auth URL
                                                                │
                Oura  ◀────────── OAuth ──────────────────────┘
                  │
                  └─▶ /api/v1/oauth/oura/callback (proxied to OW)

cron (15 min) ──▶ manage.py ow_poll
                   │
                   ├─ normalized: GET <OW>/api/v1/users/<id>/timeseries
                   └─ raw:        list S3 bucket → fetch JSON
                   │
                   └─ omh_shim.convert(...) ─▶ Observation + ObservationIdentifier

Dedup: every ingested record gets a paired ObservationIdentifier row keyed by system="ow:normalized" (or "ow:raw") and value=<omh-header.uuid>. The unique constraint on (system, value) makes re-runs idempotent.

Configuration

Environment variables (.env)

OW_API_URL="http://localhost:8001"
OW_API_KEY="sk-<your-OW-admin-API-key>"

Read by jhe/settings.py and used by both core/views/ow.py (proxy) and core/management/commands/ow_poll.py (poller).

Oura developer app (one-time, OW side)

OW performs the Oura OAuth, so the Oura developer app is configured against OW’s public URL, not JHE’s. In the Oura Cloud developer portal for the app:

  1. Open Redirect URIs.

  2. Add, byte-for-byte, <OW_PUBLIC_URL>/api/v1/oauth/oura/callback (Fly PoC: https://ow-poc.fly.dev/api/v1/oauth/oura/callback).

  3. Save.

If the redirect URI does not match exactly, Oura rejects the callback and no data ever reaches OW. For the PoC you can reuse the existing Oura developer account’s OURA_CLIENT_ID / OURA_CLIENT_SECRET (set on the OW backend, not JHE) rather than registering a new app - a dedicated dev app per environment is a later step.

Runtime toggles (JheSettings)

KeyTypeDefaultPurpose
module.owboolfalseMaster switch. ow_poll no-ops when false.
ow.ingest_modestringnormalizednormalized (HTTP) or raw (S3).
ow.sync_in_progressstring""Lock auto-managed by ow_poll. Stores the acquiring tick’s ISO timestamp (empty = unlocked). Locks older than 30 minutes are treated as abandoned and force-reclaimed by the next tick.

Raw-mode S3 settings (required when ow.ingest_mode=raw - no defaults; the poller raises a clear error if any of the four below are unset):

KeyDefault
ow.s3.endpoint_url(required)
ow.s3.access_key_id(required)
ow.s3.secret_access_key(required)
ow.s3.bucket_name(required)
ow.s3.key_prefixraw-payloads/oura/api_response

Toggle from the Django shell:

from core.models import JheSetting

s, _ = JheSetting.objects.update_or_create(
    key="module.ow", setting_id=None, defaults={"value_type": "bool"}
)
s.set_value("bool", True)
s.save()

Local Setup (from scratch)

# 1. JHE
cp dot_env_example.txt .env       # then fill OW_API_URL / OW_API_KEY
docker compose up -d db
pipenv install --dev
pipenv shell
python manage.py migrate
python manage.py seed             # creates "Oura" data source + "OW Local" client
python manage.py runserver 0.0.0.0:8000

# 2. OW backend (separate repo)
#    Clone https://github.com/the-momentum/open-wearables and follow its README.
#    Default port 8001. Generate an admin API key, paste into JHE's .env.

# 3. Enable OW module + first poll
python manage.py shell -c "
from core.models import JheSetting
s,_ = JheSetting.objects.update_or_create(key='module.ow', setting_id=None, defaults={'value_type':'bool'})
s.set_value('bool', True); s.save()
"
python manage.py ow_poll

Local Setup (Docker)

The from-scratch flow above runs JHE natively on :8000. The Docker Compose stack used for local testing maps the ports differently. These are the canonical local-test ports - keep JHE on 8001 and OW on 8000; if another service needs a port, move that service (the MyChart PoC already uses 8010 for this reason).

ServiceHost portURLSource
JHE web8001http://localhost:8001jupyterhealth-exchange/docker-compose.yml (8001:8000)
Open Wearables backend8000http://localhost:8000open-wearables/docker-compose.yml (API_PORT:-8000)
MyChart PoC (SMART)8010http://localhost:8010/smart/callbacksmart-mychart-poc/serve.py

Inside the JHE container, OW is reached at host.docker.internal:8000, so JHE’s compose sets OW_API_URL=http://host.docker.internal:8000 (overriding the .env value, which targets the native :8001 flow above).

# 1. JHE  (host 8001 -> container 8000)
cd jupyterhealth-exchange
docker compose up -d --build        # web on http://localhost:8001

# 2. Open Wearables  (host 8000)
cd ../open-wearables
docker compose up -d                # API on http://localhost:8000, /docs for Swagger

# 3. MyChart PoC  (host 8010, optional - Epic SMART standalone launch)
cd ../smart-mychart-poc
python serve.py                     # http://localhost:8010/smart/callback

Verify each service the way it is actually used:

ServiceCheckExpected
OWGET http://localhost:8000/docs200, Swagger UI
JHEGET http://localhost:8001/api/schema/swagger-ui/200, Swagger UI
JHE -> OWfrom jhe-web: reach host.docker.internal:8000200
MyChartGET http://localhost:8010/smart/callback200, PoC page

The MyChart PoC’s Epic redirect URI must be registered as http://localhost:8010/smart/callback (see the smart-mychart-poc README).

Production Setup

  1. Set OW_API_URL and OW_API_KEY in your deployment env (Fly secrets, K8s Secret, etc.).

  2. Apply migrations: python manage.py migrate.

  3. Run python manage.py seed once, or otherwise ensure the Oura DataSource + omh:heart-rate:2.0 CodeableConcept exist.

  4. Run the jhe_cron sidecar so deploy/crontab fires ow_poll every 15 minutes (the Dockerfile installs supercronic):

    # docker-compose / k8s sidecar
    command: ["supercronic", "/code/deploy/crontab"]
  5. Flip module.ow=true in JheSettings when ready to start ingesting.

End-to-End Test

StepCommand / ActionExpected
1. Unit testspython -m pytest tests/test_ow_poll.py11 passed
2. Smoke pollpython manage.py ow_pollOW poll complete (mode=normalized). Created N observations.
3. PractitionerLog in as an org manager (e.g. manager_mary@example.com), attach Oura data source to a study, generate invitation linkURL contains ?code=...; host part is your real JHE host, not localhost
4. PatientOpen invitation in incognito, sign up, agree to consents (Heart Rate checked), complete Oura OAuthLands on “Successfully Connected”
5. JheUser checkDjango admin → JheUser of the patientidentifier = "ow:<oura-user-id>"
6. Consent gateRevoke Heart Rate scope, run ow_poll0 observations created for that patient
7. IngestRe-grant Heart Rate, run ow_poll≥1 Observation created
8. DedupRun ow_poll again immediatelyCreated 0 observations.
9. ProvenanceOpen new Observation in admindata_source=Oura, coding_code=omh:heart-rate:2.0, paired ObservationIdentifier(system="ow:normalized", value=<uuid>)

ow_poll looks back one day per run and resumes from a high-water-mark, so a patient’s older Oura history is not backfilled by default. For a one-time backfill on first connect, widen the window for a single run:

python manage.py shell -c "
from datetime import timedelta
from core.management.commands import ow_poll
ow_poll.POLL_WINDOW = timedelta(days=90)
from django.core.management import call_command
call_command('ow_poll')
"

Troubleshooting

SymptomFix
OW poll skipped: module.ow=falseSet module.ow=true in JheSettings (see above).
ow_poll aborted: OW_API_URL / OW_API_KEY not configuredAdd both vars to .env and restart the server.
401 Invalid or missing API keyStale OW_API_KEY; regenerate in OW admin and update .env.
ow_poll skipped: ow.sync_in_progress since <iso>A previous tick is still running. If the timestamp is older than 30 minutes the next tick will auto-reclaim the lock and proceed; to force an immediate reclaim, set ow.sync_in_progress to "".
CodeableConcept 'omh:heart-rate:2.0' not foundRun python manage.py seed.
Patient skipped silentlyEither JheUser.identifier doesn’t start with ow:, or patient hasn’t consented to omh:heart-rate:2.0.