Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/api/biometrics/watch-upload/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,16 @@ it('imports Watch breathing rate and HRV, skips records sleepypod wrote to Healt
record('RespiratoryRate', 'Apple Watch', 14.5, 'count/min'),
record('HeartRateVariabilitySDNN', 'Apple Watch', 48, 'ms'),
record('HeartRateVariabilityRMSSD', 'Apple Watch', 0.032, 's'),
record('OxygenSaturation', 'Apple Watch', 0.97, '%'),
// The pod's own readings written back by the iOS app, even from a source named like a Watch.
record('RespiratoryRate', 'sleepypod', 99, 'count/min'),
record('HeartRateVariabilitySDNN', 'sleepypod design', 99, 'ms'),
record('HeartRate', 'sleepypod Watch', 99, 'count/min'),
].join('')}</HealthData>`
const request = () => new Request('http://pod/api/biometrics/watch-upload?side=left&days=7', { method: 'POST', body: xml })
expect(await (await POST(request())).json()).toEqual({ stages: 0, vitals: 0, breathing: 1, hrv: 1, rmssd: 1, rejectedStageBatches: 0,
matchedStages: 0, matchedVitals: 0, matchedBreathing: 1, matchedHrv: 1, matchedRmssd: 1 })
expect(requests.map(r => [r.metric, r.side, r.samples?.map(s => s.value)])).toEqual([['breathing_rate', 'left', [14.5]], ['hrv_sdnn', 'left', [48]], ['hrv_rmssd', 'left', [32]]])
expect(await (await POST(request())).json()).toEqual({ stages: 0, vitals: 0, breathing: 1, hrv: 1, rmssd: 1, oxygen: 1, rejectedStageBatches: 0,
matchedStages: 0, matchedVitals: 0, matchedBreathing: 1, matchedHrv: 1, matchedRmssd: 1, matchedOxygen: 1 })
expect(requests.map(r => [r.metric, r.side, r.samples?.map(s => s.value)])).toEqual([['breathing_rate', 'left', [14.5]], ['hrv_sdnn', 'left', [48]], ['hrv_rmssd', 'left', [32]], ['blood_oxygen', 'left', [97]]])
expect(await (await POST(request())).json()).toMatchObject({ breathing: 0, hrv: 0, matchedBreathing: 1, matchedHrv: 1, matchedRmssd: 1 })
})

Expand Down
4 changes: 2 additions & 2 deletions app/api/biometrics/watch-upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const MAX_BYTES = 512 * 1024 * 1024
const options = z.object({ side: z.enum(['left', 'right']), days: z.coerce.number().int().min(1).max(90).default(30) })
const resultSchema = z.object({ stages: z.number(), vitals: z.number(), breathing: z.number(), hrv: z.number(), rmssd: z.number().default(0), rejectedStageBatches: z.number(),
matchedStages: z.number(), matchedVitals: z.number(), matchedBreathing: z.number(), matchedHrv: z.number(), matchedRmssd: z.number().default(0) })
const resultSchema = z.object({ stages: z.number(), vitals: z.number(), breathing: z.number(), hrv: z.number(), rmssd: z.number().default(0), oxygen: z.number().default(0), rejectedStageBatches: z.number(),
matchedStages: z.number(), matchedVitals: z.number(), matchedBreathing: z.number(), matchedHrv: z.number(), matchedRmssd: z.number().default(0), matchedOxygen: z.number().default(0) })
const shared = globalThis as typeof globalThis & { watchUploadBusy?: boolean }

/**
Expand Down
11 changes: 11 additions & 0 deletions docs/biometrics-reference-imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,17 @@ the source reports them. Normalized rows accept the same `device` and `algorithm
tables across sources. Any other device, such as a ring or an Android watch, uses
the preview and commit import above with its own `source`.

### Apple Watch blood oxygen row

The Health export importer and `/reference-samples` accept `blood_oxygen` as a
percentage. Health exports write saturation as a fraction under unit `%` (`0.97`),
so the importer multiplies by 100 before the 50–100 check. Nights and Biometrics
draw it as a separate row, because the pod has no oxygen sensor to overlay it on.
The row and its legend entry appear only when a reading falls inside a drawn
session, so daytime spot checks alone add nothing. The row spans 80–100%; a lower
reading sits on the bottom edge and its hover label keeps the real value.
`scripts/import-watch-health.py --oxygen-only` imports this metric alone.

### Optional Apple Watch RMSSD overlay

The Health export importer and legacy `reference-samples` endpoints accept
Expand Down
44 changes: 27 additions & 17 deletions scripts/import-watch-health.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env -S uv run --quiet --no-project
"""
Import Apple Watch sleep stages, heart rate, breathing rate and HRV from a Health export into the pod.
Import Apple Watch sleep stages, heart rate, breathing rate, HRV and blood oxygen from a Health export into the pod.

scripts/import-watch-health.py ~/Desktop/apple_health_export --pod 192.168.1.88 --side left

Expand All @@ -13,21 +13,23 @@
POST /api/biometrics/reference-vitals HeartRate samples
POST /api/biometrics/reference-samples RespiratoryRate (breathing_rate, breaths/min)
HeartRateVariabilitySDNN (hrv_sdnn, ms)
and HeartRateVariabilityRMSSD (hrv_rmssd, ms)
HeartRateVariabilityRMSSD (hrv_rmssd, ms)
and OxygenSaturation (blood_oxygen, %)

Heart rate, breathing rate and HRV are first-write-wins on side + time +
Heart rate, breathing rate, HRV and blood oxygen are first-write-wins on side + time +
source (+ metric), so re-running on a newer export only adds what is new. Stages are stricter: a batch is rejected
(HTTP 400) when a segment overlaps a stored one with different boundaries,
e.g. a night the iOS app already posted. Such a batch is skipped with a
warning, the vitals are still imported, and the run exits 1 at the end.
Nothing is written to the pod's own `vitals` table: Watch heart rate lives in
`reference_vitals` (the Biometrics page draws it as a second line over the
pod's heart rate), breathing rate and HRV in `reference_samples`.
pod's heart rate), breathing rate, HRV and blood oxygen in `reference_samples`.

Options:
--days N only samples from the last N days (default 30; 0 = everything)
--dry-run parse and count, post nothing
--stages-only / --hr-only (heart rate alone, without breathing rate and HRV)
--oxygen-only blood oxygen alone
"""
import argparse
import importlib.util
Expand All @@ -50,11 +52,15 @@
HK_RR = 'HKQuantityTypeIdentifierRespiratoryRate'
HK_HRV = 'HKQuantityTypeIdentifierHeartRateVariabilitySDNN'
HK_RMSSD = 'HKQuantityTypeIdentifierHeartRateVariabilityRMSSD'
HK_SPO2 = 'HKQuantityTypeIdentifierOxygenSaturation'
# reference_samples metric, HK type, plausible range (the API enforces the same)
SAMPLE_METRICS = (('breathing_rate', HK_RR, 4, 60), ('hrv_sdnn', HK_HRV, 1, 500), ('hrv_rmssd', HK_RMSSD, 1, 500))
# Factors into the canonical unit (breaths/min, ms). Health exports use
# count/min and ms; anything unlisted is skipped instead of stored unconverted.
SAMPLE_UNITS = {HK_RR: {'count/min': 1, 'count/s': 60, 'Hz': 60}, HK_HRV: {'ms': 1, 's': 1000}, HK_RMSSD: {'ms': 1, 's': 1000}}
SAMPLE_METRICS = (('breathing_rate', HK_RR, 4, 60), ('hrv_sdnn', HK_HRV, 1, 500), ('hrv_rmssd', HK_RMSSD, 1, 500),
('blood_oxygen', HK_SPO2, 50, 100))
# Factors into the canonical unit (breaths/min, ms, %). Health exports use
# count/min and ms, and write saturation as a fraction under unit "%" (0.97);
# anything unlisted is skipped instead of stored unconverted.
SAMPLE_UNITS = {HK_RR: {'count/min': 1, 'count/s': 60, 'Hz': 60}, HK_HRV: {'ms': 1, 's': 1000}, HK_RMSSD: {'ms': 1, 's': 1000},
HK_SPO2: {'%': 100}}
# The sleepypod iOS app writes the pod's own HR, breathing, HRV and sleep stages
# back into Health (sourceName "sleepypod", "sleepypod design"). Those are never
# a reference, whatever --source says.
Expand Down Expand Up @@ -144,7 +150,7 @@ def export_stream(export):


def run(export, pod, side, source_pattern='Watch', days=30, dry_run=False,
stages=True, hr=True, extra=True, post=post_json, now=None, report=False):
stages=True, hr=True, extra=True, oxygen=True, post=post_json, now=None, report=False):
now = time.time() if now is None else now
cutoff = now - days * 86400 if days > 0 else float('-inf')
quantities = {hk: [] for _, hk, _, _ in SAMPLE_METRICS}
Expand All @@ -154,7 +160,8 @@ def run(export, pod, side, source_pattern='Watch', days=30, dry_run=False,

segs = [s for s in dedupe_segments(segments) if s[1] >= cutoff] if stages else []
samples = [(t, bpm) for t, bpm in hr_samples if t >= cutoff and 20 <= bpm <= 250] if hr else []
extras = {metric: [(t, v) for t, v in quantities[hk] if t >= cutoff and lo <= v <= hi] if extra else []
extras = {metric: [(t, v) for t, v in quantities[hk] if t >= cutoff and lo <= v <= hi]
if (oxygen if metric == 'blood_oxygen' else extra) else []
for metric, hk, lo, hi in SAMPLE_METRICS}

def span(times):
Expand All @@ -166,13 +173,13 @@ def span(times):

print(f'{len(segs)} stage segments ({span([s[0] for s in segs])}), '
f'{len(samples)} heart-rate samples ({span([t for t, _ in samples])}), '
f"{len(extras['breathing_rate'])} breathing-rate and {len(extras['hrv_sdnn'])} HRV samples and {len(extras['hrv_rmssd'])} RMSSD samples for side {side}",
f"{len(extras['breathing_rate'])} breathing-rate, {len(extras['hrv_sdnn'])} HRV, {len(extras['hrv_rmssd'])} RMSSD and {len(extras['blood_oxygen'])} blood-oxygen samples for side {side}",
file=sys.stderr)
if dry_run:
return {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0}
return {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0}

url = base_url(pod)
written = {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0}
written = {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0}
rejected = 0
for batch in chunks(segs):
try:
Expand All @@ -193,19 +200,20 @@ def span(times):
'samples': [{'timestamp': t, 'heartRate': bpm} for t, bpm in batch],
})
written['vitals'] += int(out.get('written', 0))
for metric, key in (('breathing_rate', 'breathing'), ('hrv_sdnn', 'hrv'), ('hrv_rmssd', 'rmssd')):
for metric, key in (('breathing_rate', 'breathing'), ('hrv_sdnn', 'hrv'), ('hrv_rmssd', 'rmssd'), ('blood_oxygen', 'oxygen')):
for batch in chunks(extras[metric]):
out = post(f'{url}/api/biometrics/reference-samples', {
'side': side, 'source': 'apple_watch', 'metric': metric,
'samples': [{'timestamp': t, 'value': v} for t, v in batch],
})
written[key] += int(out.get('written', 0))
print(f"wrote {written['stages']} new stage segments, {written['vitals']} new heart-rate, "
f"{written['breathing']} new breathing-rate and {written['hrv']} new HRV and {written['rmssd']} new RMSSD samples to {url}",
f"{written['breathing']} new breathing-rate, {written['hrv']} new HRV, {written['rmssd']} new RMSSD and {written['oxygen']} new blood-oxygen samples to {url}",
file=sys.stderr)
if report:
return {**written, 'rejectedStageBatches': rejected, 'matchedStages': len(segs), 'matchedVitals': len(samples),
'matchedBreathing': len(extras['breathing_rate']), 'matchedHrv': len(extras['hrv_sdnn']), 'matchedRmssd': len(extras['hrv_rmssd'])}
'matchedBreathing': len(extras['breathing_rate']), 'matchedHrv': len(extras['hrv_sdnn']), 'matchedRmssd': len(extras['hrv_rmssd']),
'matchedOxygen': len(extras['blood_oxygen'])}
if rejected:
raise SystemExit(f'error: {rejected} stage batch(es) rejected by the pod; vitals were still imported')
return written
Expand All @@ -223,13 +231,15 @@ def main(argv=None):
group = ap.add_mutually_exclusive_group()
group.add_argument('--stages-only', action='store_true')
group.add_argument('--hr-only', action='store_true')
group.add_argument('--oxygen-only', action='store_true')
args = ap.parse_args(argv)

export = os.path.join(args.export, 'export.xml') if os.path.isdir(args.export) else args.export
if not os.path.exists(export):
ap.error(f'{export} not found')
result = run(export, args.pod, args.side, args.source, args.days, args.dry_run,
stages=not args.hr_only, hr=not args.stages_only, extra=not (args.hr_only or args.stages_only),
stages=not (args.hr_only or args.oxygen_only), hr=not (args.stages_only or args.oxygen_only),
extra=not (args.hr_only or args.stages_only or args.oxygen_only), oxygen=not (args.hr_only or args.stages_only),
report=args.json)
if args.json:
print(json.dumps(result))
Expand Down
47 changes: 40 additions & 7 deletions scripts/tests/test_import_watch_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import os
import tempfile
import unittest
from unittest import mock
from datetime import datetime, timedelta, timezone

HERE = os.path.dirname(os.path.abspath(__file__))
Expand Down Expand Up @@ -115,14 +116,14 @@ def test_posts_stages_and_heart_rate_to_the_pod_endpoints(self):
{'timestamp': int((T0 + timedelta(minutes=6)).timestamp()), 'heartRate': 56.5},
{'timestamp': int((T0 + timedelta(minutes=11)).timestamp()), 'heartRate': 61.0},
])
self.assertEqual(written, {'stages': 3, 'vitals': 3, 'breathing': 0, 'hrv': 0, 'rmssd': 0})
self.assertEqual(written, {'stages': 3, 'vitals': 3, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0})
self.assertIn('wrote 3 new stage segments, 3 new heart-rate', err)

def test_dry_run_posts_nothing_but_reports_counts(self):
write_export(self.export, [(0, 20, 'AsleepCore')], [(1, 58)])
post, written, err = self.run_import(dry_run=True)
self.assertEqual(post.calls, [])
self.assertEqual(written, {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0})
self.assertEqual(written, {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0})
self.assertIn('1 stage segments', err)
self.assertIn('1 heart-rate samples', err)

Expand All @@ -133,7 +134,7 @@ def test_days_window_drops_old_samples(self):
self.assertEqual(len(post.calls[0][1]['segments']), 1)
self.assertEqual(len(post.calls[1][1]['samples']), 1)
_, written_all, _ = self.run_import(days=0)
self.assertEqual(written_all, {'stages': 2, 'vitals': 2, 'breathing': 0, 'hrv': 0, 'rmssd': 0})
self.assertEqual(written_all, {'stages': 2, 'vitals': 2, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 0})

def test_batches_at_the_api_limit(self):
write_export(self.export, [], [(m, 60) for m in range(0, 4500)])
Expand Down Expand Up @@ -195,8 +196,8 @@ def test_posts_breathing_rate_and_hrv_per_metric(self):
'samples': [{'timestamp': t(2), 'value': 14.5}, {'timestamp': t(7), 'value': 15.0}]})
self.assertEqual(post.calls[2][1], {'side': 'left', 'source': 'apple_watch', 'metric': 'hrv_sdnn',
'samples': [{'timestamp': t(3), 'value': 48.0}]})
self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 2, 'hrv': 1, 'rmssd': 0})
self.assertIn('2 breathing-rate and 1 HRV samples', err)
self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 2, 'hrv': 1, 'rmssd': 0, 'oxygen': 0})
self.assertIn('2 breathing-rate, 1 HRV, 0 RMSSD and 0 blood-oxygen samples', err)
# --hr-only and --stages-only leave breathing rate and HRV out.
post, _, _ = self.run_import(stages=False, extra=False)
self.assertEqual([u.rsplit('/', 1)[1] for u, _ in post.calls], ['reference-vitals'])
Expand All @@ -211,7 +212,7 @@ def test_converts_breathing_and_hrv_units_and_skips_unknown_ones(self):
t = lambda m: int((T0 + timedelta(minutes=m)).timestamp())
self.assertEqual(post.calls[1][1]['samples'], [{'timestamp': t(2), 'value': 15.0}])
self.assertEqual(post.calls[2][1]['samples'], [{'timestamp': t(4), 'value': 42.0}])
self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 1, 'hrv': 1, 'rmssd': 0})
self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 1, 'hrv': 1, 'rmssd': 0, 'oxygen': 0})

def test_never_imports_records_sleepypod_wrote_to_health(self):
at = T0 + timedelta(minutes=1)
Expand All @@ -229,7 +230,7 @@ def test_never_imports_records_sleepypod_wrote_to_health(self):
self.assertEqual(values, [58.0, 14.0, 40.0])
self.assertEqual([s['stage'] for s in post.calls[0][1]['segments']], ['light'])
_, written_any, _ = self.run_import(source_pattern='.')
self.assertEqual(written_any, {'stages': 1, 'vitals': 1, 'breathing': 1, 'hrv': 1, 'rmssd': 0})
self.assertEqual(written_any, {'stages': 1, 'vitals': 1, 'breathing': 1, 'hrv': 1, 'rmssd': 0, 'oxygen': 0})

def test_rmssd_remains_distinct_and_preserves_breathing(self):
at = T0 + timedelta(minutes=2)
Expand All @@ -242,6 +243,38 @@ def test_rmssd_remains_distinct_and_preserves_breathing(self):
self.assertEqual([p['samples'][0]['value'] for _, p in post.calls], [14, 48, 32])
self.assertEqual(written['rmssd'], 1)

def test_blood_oxygen_fraction_is_stored_as_a_percentage(self):
at = T0 + timedelta(minutes=2)
extra = (quantity_record('OxygenSaturation', at, 0.97, '%')
# A percentage, not a fraction, lands out of range rather than as 9700%.
+ quantity_record('OxygenSaturation', at + timedelta(minutes=1), 97, '%')
+ quantity_record('OxygenSaturation', at + timedelta(minutes=2), 0.4, '%')
+ quantity_record('OxygenSaturation', at + timedelta(minutes=3), 0.96, 'ratio')
+ quantity_record('OxygenSaturation', at + timedelta(minutes=4), 0.95, '%', source='sleepypod Watch'))
write_export(self.export, [], [], extra=extra)
post, written, _ = self.run_import()
self.assertEqual([(p['metric'], p['source'], p['samples'][0]['value'], len(p['samples'])) for _, p in post.calls],
[('blood_oxygen', 'apple_watch', 97, 1)])
self.assertEqual(written['oxygen'], 1)

def test_oxygen_only_leaves_every_other_metric_alone(self):
at = T0 + timedelta(minutes=2)
write_export(self.export, [(0, 20, 'AsleepCore')], [(2, 58)], rr=[(2, 14)], hrv=[(2, 48)],
extra=quantity_record('OxygenSaturation', at, 0.97, '%'))
post, written, _ = self.run_import(stages=False, hr=False, extra=False)
self.assertEqual([p.get('metric') for _, p in post.calls], ['blood_oxygen'])
self.assertEqual(written, {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0, 'rmssd': 0, 'oxygen': 1})

def test_only_flags_choose_what_is_imported(self):
write_export(self.export, [], [])
cases = {'--oxygen-only': (False, False, False, True), '--stages-only': (True, False, False, False),
'--hr-only': (False, True, False, False)}
for flag, (stages, hr, extra, oxygen) in cases.items():
with mock.patch.object(iw, 'run', return_value={}) as run:
iw.main([self.export, '--pod', 'x', '--side', 'left', flag])
self.assertEqual({k: run.call_args.kwargs[k] for k in ('stages', 'hr', 'extra', 'oxygen')},
{'stages': stages, 'hr': hr, 'extra': extra, 'oxygen': oxygen}, flag)

def test_base_url_forms(self):
self.assertEqual(iw.base_url('192.168.1.88'), 'http://192.168.1.88:3000')
self.assertEqual(iw.base_url('pod.local:8080'), 'http://pod.local:8080')
Expand Down
Loading
Loading