"""SQLite storage layer: schema init, idempotent upserts, and read queries. Source-agnostic — the Fit REST adapter and the Takeout importer both call the same upsert_* functions. Reads (summary/series/sessions/sleep) back the Adolf query CLI. All writes are UPSERTs keyed on the row's natural Fit identity, so a re-sync over an overlapping window is a no-op rather than a duplicate. """ import json import os import sqlite3 from datetime import datetime, timezone SCHEMA_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "schema.sql") def _now_iso(): return datetime.now(tz=timezone.utc).isoformat() def connect(db_path): os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True) conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn def init_db(conn): with open(SCHEMA_PATH) as f: conn.executescript(f.read()) conn.commit() # --- writes --------------------------------------------------------------- def upsert_data_points(conn, rows, source="ha"): now = _now_iso() n = 0 for r in rows: conn.execute( """ INSERT INTO data_points (metric, data_type_name, start_ns, end_ns, start_time, end_time, value_int, value_float, value_str, unit, data_source_id, source, ingested_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(metric, start_ns, end_ns, data_source_id) DO UPDATE SET value_int=excluded.value_int, value_float=excluded.value_float, value_str=excluded.value_str, unit=excluded.unit, data_type_name=excluded.data_type_name, source=excluded.source, ingested_at=excluded.ingested_at """, ( r["metric"], r["data_type_name"], r["start_ns"], r["end_ns"], r["start_time"], r["end_time"], r.get("value_int"), r.get("value_float"), r.get("value_str"), r.get("unit"), r.get("data_source_id", ""), source, now, ), ) n += 1 conn.commit() return n def upsert_sleep_segments(conn, rows, source="ha"): now = _now_iso() n = 0 for r in rows: conn.execute( """ INSERT INTO sleep_segments (start_ns, end_ns, start_time, end_time, stage, stage_name, data_source_id, source, ingested_at) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(start_ns, end_ns, data_source_id) DO UPDATE SET stage=excluded.stage, stage_name=excluded.stage_name, source=excluded.source, ingested_at=excluded.ingested_at """, ( r["start_ns"], r["end_ns"], r["start_time"], r["end_time"], r["stage"], r["stage_name"], r.get("data_source_id", ""), source, now, ), ) n += 1 conn.commit() return n def upsert_sessions(conn, rows, source="ha"): now = _now_iso() n = 0 for r in rows: raw = r.get("raw_json") if raw is not None and not isinstance(raw, str): raw = json.dumps(raw) conn.execute( """ INSERT INTO sessions (id, name, description, activity_type, activity_name, start_ns, end_ns, start_time, end_time, modified_ns, application, source, raw_json, ingested_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, activity_type=excluded.activity_type, activity_name=excluded.activity_name, start_ns=excluded.start_ns, end_ns=excluded.end_ns, start_time=excluded.start_time, end_time=excluded.end_time, modified_ns=excluded.modified_ns, application=excluded.application, source=excluded.source, raw_json=excluded.raw_json, ingested_at=excluded.ingested_at """, ( r["id"], r.get("name"), r.get("description"), r.get("activity_type"), r.get("activity_name"), r["start_ns"], r["end_ns"], r["start_time"], r["end_time"], r.get("modified_ns"), r.get("application"), source, raw, now, ), ) n += 1 conn.commit() return n # --- sync cursor & run audit --------------------------------------------- def get_last_synced_ns(conn, stream_key): row = conn.execute( "SELECT last_synced_ns FROM sync_state WHERE stream_key=?", (stream_key,) ).fetchone() return row["last_synced_ns"] if row else 0 def set_sync_state(conn, stream_key, last_synced_ns, status="ok", error=None): conn.execute( """ INSERT INTO sync_state (stream_key, last_synced_ns, last_run_at, last_status, last_error) VALUES (?,?,?,?,?) ON CONFLICT(stream_key) DO UPDATE SET last_synced_ns=MAX(sync_state.last_synced_ns, excluded.last_synced_ns), last_run_at=excluded.last_run_at, last_status=excluded.last_status, last_error=excluded.last_error """, (stream_key, last_synced_ns, _now_iso(), status, error), ) conn.commit() def start_run(conn): cur = conn.execute( "INSERT INTO ingest_runs (started_at, status) VALUES (?, 'running')", (_now_iso(),) ) conn.commit() return cur.lastrowid def finish_run(conn, run_id, status, points=0, sessions=0, segments=0, error=None): conn.execute( """UPDATE ingest_runs SET finished_at=?, status=?, points_upserted=?, sessions_upserted=?, segments_upserted=?, error=? WHERE id=?""", (_now_iso(), status, points, sessions, segments, error, run_id), ) conn.commit() # --- reads (Adolf query tool) -------------------------------------------- def daily_metric(conn, metric, days=14): return [dict(r) for r in conn.execute( """SELECT date(start_time) AS day, SUM(COALESCE(value_int, value_float)) AS total, MAX(unit) AS unit FROM data_points WHERE metric=? AND start_time >= datetime('now', ?) GROUP BY day ORDER BY day DESC""", (metric, f"-{int(days)} days"), ).fetchall()] def latest_metric(conn, metric, limit=20): return [dict(r) for r in conn.execute( """SELECT start_time, end_time, value_int, value_float, unit FROM data_points WHERE metric=? ORDER BY start_ns DESC LIMIT ?""", (metric, int(limit)), ).fetchall()] def recent_sessions(conn, days=30, limit=50): return [dict(r) for r in conn.execute( """SELECT id, name, activity_type, activity_name, start_time, end_time, (end_ns - start_ns)/60000000000.0 AS duration_min FROM sessions WHERE start_time >= datetime('now', ?) ORDER BY start_ns DESC LIMIT ?""", (f"-{int(days)} days", int(limit)), ).fetchall()] def sleep_by_night(conn, days=14): """Total minutes per stage grouped by the calendar day the sleep segment ends (a night that crosses midnight is attributed to the wake day).""" return [dict(r) for r in conn.execute( """SELECT date(end_time) AS night, stage_name, SUM((end_ns - start_ns)/60000000000.0) AS minutes FROM sleep_segments WHERE end_time >= datetime('now', ?) GROUP BY night, stage_name ORDER BY night DESC""", (f"-{int(days)} days",), ).fetchall()] def summary(conn): """Compact health snapshot for Adolf: row counts, coverage, freshness.""" out = {} for name, q in ( ("data_points", "SELECT COUNT(*) c FROM data_points"), ("sessions", "SELECT COUNT(*) c FROM sessions"), ("sleep_segments", "SELECT COUNT(*) c FROM sleep_segments"), ): out[name] = conn.execute(q).fetchone()["c"] out["metrics"] = [r["metric"] for r in conn.execute( "SELECT DISTINCT metric FROM data_points ORDER BY metric").fetchall()] span = conn.execute( "SELECT MIN(start_time) a, MAX(start_time) b FROM data_points").fetchone() out["coverage"] = {"earliest": span["a"], "latest": span["b"]} out["sync_state"] = [dict(r) for r in conn.execute( "SELECT stream_key, last_run_at, last_status FROM sync_state").fetchall()] last = conn.execute( "SELECT started_at, finished_at, status, points_upserted FROM ingest_runs " "ORDER BY id DESC LIMIT 1").fetchone() out["last_run"] = dict(last) if last else None return out