AHDX: Apple Health exporter with SQLite + Grafana dashboard (MIT)

This commit is contained in:
Steffen Skui
2026-07-21 21:23:34 +02:00
commit 850c13777c
34 changed files with 5108 additions and 0 deletions
+481
View File
@@ -0,0 +1,481 @@
"""
AHDX web app.
Small Flask front end over the storage layer: a page to import an Apple Health
export, a dashboard, a browser for the raw records, database management, and a
JSON endpoint the phone can push to. All local, no accounts, no outbound calls.
"""
import base64
import csv
import io
import os
import secrets
import threading
import urllib.request
from flask import (
Flask, Response, abort, flash, jsonify, redirect, render_template, request,
session, url_for,
)
from werkzeug.utils import secure_filename
import db
import jobs
import scanner
def _truthy(v):
return str(v).strip().lower() in ("1", "true", "yes", "on")
# Auth ships ON. AHDX keeps its own salted password in the registry DB, set once
# via the GUI on first run. When you set it, AHDX also pushes it to Grafana (see
# _sync_grafana_password) so one password covers both. The /ingest push endpoint
# stays open; it's for the LAN.
AUTH_ENABLED = _truthy(os.environ.get("AHDX_AUTH", "true"))
GRAFANA_URL = os.environ.get("AHDX_GRAFANA_URL", "http://ahdx-grafana:3000").rstrip("/")
def _has_password():
return db.has_password()
def _check_password(pw):
return db.check_password(pw)
def _sync_grafana_password(new_pw):
"""Best-effort: set Grafana's admin password to match. A fresh Grafana ships
as admin/admin, so we change it from there on first setup; on later changes
we try the new password as the "current" one too. Failure is ignored — AHDX
still works, Grafana just keeps whatever it had."""
import json as _json
for current in ("admin", new_pw):
try:
auth = base64.b64encode(f"admin:{current}".encode()).decode()
body = _json.dumps({"oldPassword": current, "newPassword": new_pw}).encode()
req = urllib.request.Request(
GRAFANA_URL + "/api/user/password", data=body, method="PUT",
headers={"Authorization": "Basic " + auth, "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as r:
if r.status == 200:
return True
except Exception:
continue
return False
# Optional shared key for the read API. If set, callers (Grafana, scripts) must
# send it as the X-Api-Key header or a ?key= query param. If unset, the API is
# open on the LAN, same as /ingest.
API_KEY = os.environ.get("AHDX_API_KEY") or None
app = Flask(__name__)
with app.app_context():
db.init()
# A stable session secret. Prefer the env var; otherwise keep a random one in the
# settings table so logins survive a restart without hard-coding anything.
_secret = os.environ.get("SECRET_KEY") or db.get_setting("secret_key")
if not _secret:
_secret = secrets.token_hex(32)
db.set_setting("secret_key", _secret)
app.config["SECRET_KEY"] = _secret
scanner.start()
# Endpoints reachable without a login: the login/setup pages themselves, the
# health check, static files, and the machine-to-machine push endpoint.
OPEN_ENDPOINTS = {"login", "setup", "health", "static", "ingest"}
@app.before_request
def require_login():
# The read API is machine-to-machine: it has its own key, so the browser
# login never applies to it.
if request.path.startswith("/api"):
return
if not AUTH_ENABLED or session.get("authed") or request.endpoint in OPEN_ENDPOINTS:
return
# First run with auth on and no password yet: force setting one.
return redirect(url_for("setup" if not _has_password() else "login"))
def _api_guard():
"""Return an error response if the API key is required and missing/wrong,
else None."""
if not API_KEY:
return None
given = request.headers.get("X-Api-Key") or request.args.get("key")
if given != API_KEY:
return jsonify({"error": "unauthorized: send the X-Api-Key header"}), 401
return None
@app.context_processor
def inject_globals():
# Every page shows the database picker, so hand it the list each render.
return {
"databases": db.list_databases(),
"active_db": db.get_active(),
"auth_enabled": AUTH_ENABLED,
}
# ---------- auth (only active when AHDX_AUTH is on) ----------
@app.route("/setup", methods=["GET", "POST"])
def setup():
if not AUTH_ENABLED or _has_password():
return redirect(url_for("index"))
if request.method == "POST":
pw = request.form.get("password", "")
confirm = request.form.get("confirm", "")
if len(pw) < 6:
flash("Use at least 6 characters.", "error")
elif pw != confirm:
flash("The two passwords don't match.", "error")
else:
db.set_password(pw)
_sync_grafana_password(pw) # make it the Grafana admin password too
session["authed"] = True
flash("Password set — it's now the login for both AHDX and Grafana.", "success")
return redirect(url_for("index"))
return render_template("auth.html", mode="setup")
@app.route("/login", methods=["GET", "POST"])
def login():
if not AUTH_ENABLED:
return redirect(url_for("index"))
if not _has_password():
return redirect(url_for("setup"))
if request.method == "POST":
if _check_password(request.form.get("password", "")):
session["authed"] = True
return redirect(url_for("index"))
flash("Wrong password.", "error")
return render_template("auth.html", mode="login")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login") if AUTH_ENABLED else url_for("index"))
@app.route("/health")
def health():
return {"ok": True}
# ---------- import ----------
@app.route("/")
def index():
return render_template("import.html", status=db.import_status())
@app.route("/import", methods=["POST"])
def do_import():
f = request.files.get("file")
if not f or not f.filename:
flash("Pick your Apple Health export first.", "error")
return redirect(url_for("index"))
if jobs.import_running():
flash("An import is already running. Give it a moment.", "error")
return redirect(url_for("index"))
saved = os.path.join(db.UPLOAD_DIR, "upload_" + secure_filename(f.filename))
f.save(saved)
# Parse off the request thread; the page polls /import/status for progress.
threading.Thread(
target=jobs.run_import, args=(saved, "upload:" + f.filename), daemon=True
).start()
flash("Import started.", "success")
return redirect(url_for("index"))
@app.route("/import/status")
def import_status():
s = db.import_status()
return jsonify({k: s[k] for k in s.keys()})
# ---------- dashboard + browse ----------
@app.route("/dashboard")
def dashboard():
return render_template("dashboard.html", cards=db.dashboard_cards(), **db.dashboard_stats())
@app.route("/trends")
def trends():
types = db.record_types()
sel = request.args.get("type") or (types[0] if types else None)
agg = request.args.get("agg", "avg")
if agg not in ("avg", "sum", "min", "max", "count"):
agg = "avg"
points = db.daily_rollup(sel, agg) if sel else []
stats = db.type_stats(sel) if sel else None
return render_template(
"trends.html", types=types, sel_type=sel or "", agg=agg,
points=points, stats=stats,
)
@app.route("/browse")
def browse():
type_ = request.args.get("type") or None
start = request.args.get("start") or None
end = request.args.get("end") or None
rows = db.browse(type_, start, end, limit=500)
return render_template(
"browse.html", rows=rows, types=db.record_types(),
sel_type=type_ or "", start=start or "", end=end or "",
)
@app.route("/export.csv")
def export_csv():
type_ = request.args.get("type") or None
start = request.args.get("start") or None
end = request.args.get("end") or None
rows = db.browse(type_, start, end, limit=5_000_000)
def generate():
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["type", "source_name", "unit", "value", "start_date", "end_date"])
yield buf.getvalue()
buf.seek(0); buf.truncate(0)
for r in rows:
writer.writerow([r["type"], r["source_name"], r["unit"], r["value"],
r["start_date"], r["end_date"]])
yield buf.getvalue()
buf.seek(0); buf.truncate(0)
return Response(
generate(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=ahdx-records.csv"},
)
# ---------- routes + ECG ----------
@app.route("/routes")
def routes():
return render_template("routes.html", routes=db.list_routes())
@app.route("/routes/<int:route_id>")
def route_detail(route_id):
route, points = db.get_route(route_id)
if not route:
abort(404)
return render_template("route_detail.html", route=route, points=points)
@app.route("/ecg")
def ecg():
return render_template("ecg.html", items=db.list_ecg())
@app.route("/ecg/<int:ecg_id>")
def ecg_detail(ecg_id):
row, samples = db.get_ecg(ecg_id)
if not row:
abort(404)
return render_template("ecg_detail.html", ecg=row, samples=samples)
# ---------- databases ----------
@app.route("/databases")
def databases():
return render_template("databases.html", overview=db.database_overview())
@app.route("/databases/new", methods=["POST"])
def new_database():
try:
db.create_database(request.form.get("name", ""))
flash("Database created and set active.", "success")
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("databases"))
@app.route("/databases/<int:db_id>/rename", methods=["POST"])
def rename_database(db_id):
try:
db.rename_database(db_id, request.form.get("name", ""))
flash("Database renamed.", "success")
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("databases"))
@app.route("/databases/<int:db_id>/activate", methods=["POST"])
def activate_database(db_id):
db.set_active(db_id)
flash("Switched active database.", "success")
return redirect(request.referrer or url_for("databases"))
@app.route("/databases/<int:db_id>/delete", methods=["POST"])
def delete_database(db_id):
try:
db.delete_database(db_id)
flash("Database deleted.", "success")
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("databases"))
# ---------- push endpoint for iOS Shortcuts ----------
@app.route("/ingest", methods=["POST"])
def ingest():
"""Accept a batch of records as JSON and merge them into the active database.
Body is either a list of record objects or {"records": [...]}. A record is
{type, value, unit, start_date, end_date, source_name}. Same dedup as the
XML import, so re-sending an overlapping window is harmless."""
data = request.get_json(silent=True)
rows = data.get("records") if isinstance(data, dict) else data
if not isinstance(rows, list):
return jsonify({"error": 'send a JSON list, or {"records": [...]}'}), 400
added = db.ingest_records(rows)
return jsonify({"received": len(rows), "added": added})
# ---------- read API (for Grafana, scripts, etc.) ----------
@app.route("/api")
def api_index():
"""The menu. Lists the databases and every endpoint so you can discover the
API by opening this one URL."""
guard = _api_guard()
if guard:
return guard
base = request.host_url.rstrip("/")
names = [d["name"] for d in db.list_databases()]
example_db = names[0] if names else "Steffen"
return jsonify({
"app": "AHDX read API",
"databases": names,
"how": "Add ?db=<name> to target a database. Leave it off to use the active one.",
"auth": "open" if not API_KEY else "send X-Api-Key header (or ?key=)",
"endpoints": [
{"path": "/api/databases", "returns": "the list of databases"},
{"path": "/api/types", "params": ["db"], "returns": "record types with counts"},
{"path": "/api/records", "params": ["db", "type", "from", "to", "limit"],
"returns": "raw records"},
{"path": "/api/daily", "params": ["db", "type", "agg", "from", "to"],
"returns": "one value per day; agg is avg|sum|min|max|count. Best for Grafana."},
{"path": "/api/workouts", "params": ["db"], "returns": "workouts"},
{"path": "/api/activity", "params": ["db"], "returns": "daily activity summaries"},
{"path": "/api/sleep", "params": ["db", "from", "to"],
"returns": "one row per night: hours per stage + a 0-100 score"},
{"path": "/api/routes", "params": ["db"], "returns": "GPS routes list"},
{"path": "/api/route", "params": ["db", "id"], "returns": "one route's lat/lon points"},
],
"examples": [
f"{base}/api/types?db={example_db}",
f"{base}/api/daily?db={example_db}&type=HKQuantityTypeIdentifierStepCount&agg=sum",
f"{base}/api/daily?db={example_db}&type=HKQuantityTypeIdentifierHeartRate&agg=avg&from=2026-01-01",
],
})
@app.route("/api/databases")
def api_databases():
guard = _api_guard()
if guard:
return guard
return jsonify([
{"name": d["name"], "file": d["filename"], "active": bool(d["is_active"])}
for d in db.list_databases()
])
def _with_db(fn):
"""Run a db.api_* call, turning an unknown ?db= into a clean 404."""
guard = _api_guard()
if guard:
return guard
try:
return jsonify(fn())
except KeyError:
return jsonify({"error": "unknown database; see /api/databases"}), 404
@app.route("/api/types")
def api_types():
return _with_db(lambda: db.api_types(request.args.get("db")))
@app.route("/api/records")
def api_records():
type_ = request.args.get("type")
if not type_:
return jsonify({"error": "type is required; see /api/types"}), 400
limit = min(int(request.args.get("limit", 10000)), 200000)
return _with_db(lambda: db.api_records(
request.args.get("db"), type_, request.args.get("from"),
request.args.get("to"), limit,
))
@app.route("/api/daily")
def api_daily():
type_ = request.args.get("type")
if not type_:
return jsonify({"error": "type is required; see /api/types"}), 400
agg = request.args.get("agg", "avg")
if agg not in ("avg", "sum", "min", "max", "count"):
agg = "avg"
return _with_db(lambda: db.api_daily(
request.args.get("db"), type_, agg,
request.args.get("from"), request.args.get("to"),
))
@app.route("/api/workouts")
def api_workouts():
return _with_db(lambda: db.api_workouts(request.args.get("db")))
@app.route("/api/activity")
def api_activity():
return _with_db(lambda: db.api_activity(request.args.get("db")))
@app.route("/api/sleep")
def api_sleep():
return _with_db(lambda: db.api_sleep(
request.args.get("db"), request.args.get("from"), request.args.get("to")))
@app.route("/api/routes")
def api_routes():
return _with_db(lambda: db.api_routes(request.args.get("db")))
@app.route("/api/route")
def api_route():
# Missing / empty / unresolved-variable id -> latest route (handled in db).
rid = request.args.get("id")
rid = int(rid) if (rid and rid.isdigit()) else None
return _with_db(lambda: db.api_route_points(request.args.get("db"), rid))
@app.route("/api/route/stats")
def api_route_stats():
rid = request.args.get("id")
rid = int(rid) if (rid and rid.isdigit()) else None
return _with_db(lambda: db.api_route_stats(request.args.get("db"), rid))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))