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:
+150
-18
@@ -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,22 +115,116 @@ 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
|
||||
|
||||
|
||||
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(
|
||||
ENTRIES_URL,
|
||||
MEASUREMENTS_URL,
|
||||
json=payload,
|
||||
auth=NC_AUTH,
|
||||
headers={
|
||||
@@ -130,6 +235,10 @@ def forward_weight_entry(record: dict) -> None:
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user