bridge: add steps and pulse via Health's Daily Values / Measurements APIs

Health v3 splits writable metrics across three distinct endpoint families
instead of one unified /entries resource (per nextcloud/health's
src/metrics.ts and src/api/*.ts, which are more current than its Markdown
docs): journal entries, one-off measurements, and per-day upserted daily
values.

- weight and steps are Daily Values (PUT /daily-values/{metricKey}/{date});
  steps are sourced from Companion's daily_totals aggregate (already
  deduplicated across sources) instead of summing raw per-window records.
- pulse (heart rate) is a Measurement (POST /measurements); handles both
  raw records and bucketed resolution windows, using the window average
  and a deterministic id for the latter since they carry no HC uuid.
- Both endpoints require an explicit unit (weight: kg, steps: steps,
  pulse: bpm) and Measurements only accept context manual/checkin/checkout,
  not an automation-specific value -- both previously caused 400s.
- sleep_duration is dropped: despite being documented, it isn't an actual
  Daily Value, Measurement, or journal metric in the shipped app (absent
  from GET /configuration entirely).
- Logging now reports per-metric success/failure counts and uses Health's
  metric-key vocabulary consistently instead of mixing in Companion's raw
  payload field names.
This commit is contained in:
Matthias Jacob
2026-09-17 03:48:47 +02:00
parent b3ed4cf430
commit 2370254e23
+161 -29
View File
@@ -1,9 +1,18 @@
#!/usr/bin/env python3
"""Minimal bridge: Life Dashboard Companion webhook -> Nextcloud Health v3.
MVP scope: only the `weight` array of the Health Connect payload is forwarded,
since that is the one OpenScale-relevant metric already built into Health v3.
Everything else in the payload is ignored for now.
Scope: only forwards the Health Connect types that are both sent by the
Companion app and already supported by Health v3: weight and steps (Daily
Values, one upsert per calendar day), and pulse/heart rate (a Measurement).
Which metrics a given Health instance actually knows is detected at startup
rather than assumed, since shipped releases can lag behind the documented
API. Sleep duration isn't a Daily Value, Measurement, or journal metric in
Health v3 yet at all, despite being documented, so it can't be mapped here
either. Body fat, blood glucose, blood pressure, oxygen saturation and
temperature aren't sent by the Companion app at all yet. Hydration and
exercise exist in both apps but with incompatible shapes (a volume/duration
vs. a closed set of event options) and would need lossy guessing, so they
are skipped for now too.
Run:
NC_WEBHOOK_SECRET=... python server.py
@@ -12,6 +21,7 @@ import json
import logging
import os
import uuid
from datetime import datetime
from hashlib import sha256
from hmac import compare_digest, new as hmac_new
from pathlib import Path
@@ -32,7 +42,8 @@ credentials = json.loads(CREDENTIALS_FILE.read_text())
NC_SERVER = credentials["server"]
NC_AUTH = (credentials["login_name"], credentials["app_password"])
HEALTH_API = f"{NC_SERVER}/ocs/v2.php/apps/health/api/v2"
ENTRIES_URL = f"{HEALTH_API}/entries"
DAILY_VALUES_URL = f"{HEALTH_API}/daily-values"
MEASUREMENTS_URL = f"{HEALTH_API}/measurements"
CONFIGURATION_URL = f"{HEALTH_API}/configuration"
# Identifies this bridge in Nextcloud's server logs and admin UI instead of the
# default "python-requests/x.y.z".
@@ -41,7 +52,7 @@ USER_AGENT = "PrivateHealthBridge"
NC_TIMEOUT_SECONDS = float(os.environ.get("NC_TIMEOUT_SECONDS", "30"))
# Every metric this bridge can forward; new users only have "stress" enabled
# by default, so writes would otherwise fail with an "unsupported metric" error.
WANTED_METRICS = ("weight",)
WANTED_METRICS = ("weight", "steps", "pulse")
# Filled at startup from the server's actual configuration response, since a
# Health instance may not know a metric yet at all or may just have it
# disabled for this user.
@@ -104,32 +115,130 @@ def as_operation_id(record_uuid: str | None) -> str | None:
return None
def forward_weight_entry(record: dict) -> None:
def deterministic_operation_id(*parts: str) -> str:
"""Stable id for records that carry no HC uuid (e.g. bucketed windows)."""
return str(uuid.uuid5(uuid.NAMESPACE_URL, "|".join(parts)))
def parse_rfc3339(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def local_date(iso_timestamp: str) -> str:
# Approximation: uses the UTC calendar date since we don't know the
# Nextcloud user's timezone here; fine for anything but readings taken
# right around midnight.
return parse_rfc3339(iso_timestamp).date().isoformat()
# Canonical unit Health expects per Daily Value metric (src/metrics.ts's
# getMetricUnits(); "null" is rejected as "Unsupported unit or numeric value").
DAILY_VALUE_UNITS = {"weight": "kg", "steps": "steps"}
def build_daily_value(metric_key: str, date: str, numeric_value: float) -> dict:
return {"kind": "daily_value", "metricKey": metric_key, "date": date, "numericValue": numeric_value}
# Canonical unit Health expects per Measurement metric (src/metrics.ts's
# getMetricUnits()).
MEASUREMENT_UNITS = {"pulse": "bpm"}
def build_measurement(
metric_key: str, numeric_value: float, recorded_at: str, operation_id: str | None
) -> dict:
payload = {
"metricKey": "weight",
"numericValue": record["kilograms"],
"optionValue": None,
"context": "api",
"kind": "measurement",
"metricKey": metric_key,
"numericValue": numeric_value,
"values": None,
"unit": MEASUREMENT_UNITS[metric_key],
# "api" isn't a valid Measurement context (only manual/checkin/checkout);
# unlike Entries and Daily Values there's no dedicated automation value.
"context": "manual",
"source": "api",
"recordedAt": record["time"],
"recordedAt": recorded_at,
"note": None,
}
operation_id = as_operation_id(record.get("uuid"))
if operation_id:
payload["operationId"] = operation_id
return payload
resp = requests.post(
ENTRIES_URL,
json=payload,
auth=NC_AUTH,
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
"User-Agent": USER_AGENT,
},
params={"format": "json"},
timeout=NC_TIMEOUT_SECONDS,
)
def extract_entries(body: dict) -> list[dict]:
entries = []
if "weight" in supported_metrics:
for record in body.get("weight", []):
entries.append(build_daily_value("weight", local_date(record["time"]), record["kilograms"]))
if "steps" in supported_metrics:
# Health Connect's own aggregate, deduplicated across sources (phone,
# watch, ...) -- summing the raw per-window records ourselves would
# double-count. See docs/webhook.md#daily-totals.
for totals in body.get("daily_totals", []):
if "steps" in totals:
entries.append(build_daily_value("steps", totals["date"], totals["steps"]))
if "pulse" in supported_metrics:
for record in body.get("heart_rate", []):
if "bucket_start" in record:
# Bucketed resolution: no single sample, so use the window's
# average as the representative pulse value.
entries.append(
build_measurement(
"pulse",
record["avg"],
record["bucket_start"],
deterministic_operation_id("pulse", record["bucket_start"]),
)
)
else:
entries.append(
build_measurement(
"pulse",
record["bpm"],
record["time"],
as_operation_id(record.get("uuid")),
)
)
return entries
def forward_entry(entry: dict) -> None:
if entry["kind"] == "daily_value":
resp = requests.put(
f"{DAILY_VALUES_URL}/{entry['metricKey']}/{entry['date']}",
json={"numericValue": entry["numericValue"], "unit": DAILY_VALUE_UNITS[entry["metricKey"]]},
auth=NC_AUTH,
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
"User-Agent": USER_AGENT,
},
params={"format": "json"},
timeout=NC_TIMEOUT_SECONDS,
)
else:
payload = {k: v for k, v in entry.items() if k != "kind"}
resp = requests.post(
MEASUREMENTS_URL,
json=payload,
auth=NC_AUTH,
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
"User-Agent": USER_AGENT,
},
params={"format": "json"},
timeout=NC_TIMEOUT_SECONDS,
)
if not resp.ok:
# Nextcloud's error body has the actual validation reason; the plain
# status code alone isn't enough to debug a 400.
log.error("Nextcloud rejected %s: %s %s -> %s", entry["kind"], resp.status_code, entry, resp.text)
resp.raise_for_status()
@@ -141,16 +250,38 @@ def health_connect_webhook():
return "invalid signature", 401
body = request.get_json(force=True)
weight_records = body.get("weight", [])
log.info("Received %d weight record(s)", len(weight_records))
entries = extract_entries(body)
# Map Companion's payload field names to Health's metric keys so this log
# line uses the same vocabulary as "supported metrics" below.
present_types = [
metric_key
for payload_key, metric_key in (("weight", "weight"), ("daily_totals", "steps"), ("heart_rate", "pulse"))
if body.get(payload_key)
]
log.info(
"Payload contains %s; forwarding %d entr(y/ies) (supported metrics: %s)",
present_types,
len(entries),
sorted(supported_metrics),
)
failures = 0
for record in weight_records:
ok_counts: dict[str, int] = {}
failure_counts: dict[str, int] = {}
for entry in entries:
metric_key = entry["metricKey"]
try:
forward_weight_entry(record)
forward_entry(entry)
except requests.RequestException:
log.exception("Failed to forward weight record %s", record.get("uuid"))
log.exception("Failed to forward %s entry", metric_key)
failures += 1
failure_counts[metric_key] = failure_counts.get(metric_key, 0) + 1
else:
ok_counts[metric_key] = ok_counts.get(metric_key, 0) + 1
# The per-entry log lines above are easy to miss in a busy log; this
# summary makes it obvious which metrics actually made it through.
log.info("Forwarded ok: %s, failed: %s", ok_counts, failure_counts)
if failures:
# Non-2xx makes the Companion app retry the whole payload later.
@@ -162,3 +293,4 @@ def health_connect_webhook():
if __name__ == "__main__":
discover_supported_metrics()
app.run(host="0.0.0.0", port=8080)