""" The rest of what Garmin holds. The original sync covered daily totals, activities, badges and personal records — 16 of the library's 57 endpoints. Everything here is data the account actually has that was simply never being stored: body composition, hill score, race predictions, hydration, the within-day sample series, and challenges and devices. All of it lands in the local database during a sync, so no screen ever has to reach Garmin to draw itself. """ import datetime import json from db import execute, query_one, query_all from config import DB_TYPE # --- small helpers ----------------------------------------------------------- def _num(*values): for v in values: if v is None or v == "": continue try: return float(v) except (TypeError, ValueError): continue return None def _int(*values): n = _num(*values) return int(n) if n is not None else None def _safe(fn, default=None): try: return fn() except Exception: # noqa: BLE001 - a missing feature must not fail a sync return default def _day(value): """Garmin dates arrive as ISO strings, epoch millis, or already-dates.""" if value is None or value == "": return None if isinstance(value, datetime.date): return value.isoformat() text = str(value) if text.isdigit(): seconds = int(text) / (1000 if len(text) > 10 else 1) return datetime.datetime.utcfromtimestamp(seconds).date().isoformat() return text[:10] def _stamp(value): if value is None or value == "": return None text = str(value) if text.isdigit(): seconds = int(text) / (1000 if len(text) > 10 else 1) return datetime.datetime.utcfromtimestamp(seconds).isoformat(timespec="seconds") return text.replace("T", " ")[:19] def _upsert(table, key_cols, cols, values): placeholders = ", ".join(["?"] * len(cols)) updatable = [c for c in cols if c not in key_cols] if DB_TYPE == "mariadb": updates = ", ".join(f"{c}=VALUES({c})" for c in updatable) sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) " f"ON DUPLICATE KEY UPDATE {updates}") else: conflict = ", ".join(key_cols) updates = ", ".join(f"{c}=excluded.{c}" for c in updatable) sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) " f"ON CONFLICT({conflict}) DO UPDATE SET {updates}") execute(sql, values) # --- body composition -------------------------------------------------------- def sync_body_composition(client, user_id, start, end): """Weight and everything a connected scale reports with it.""" data = _safe(lambda: client.get_body_composition(start, end), {}) or {} rows = data.get("dateWeightList") or [] stored = 0 for row in rows: date = _day(row.get("calendarDate") or row.get("date")) if not date: continue # Garmin stores weight in grams. grams = _num(row.get("weight")) _upsert( "body_composition", ("user_id", "date"), ["id", "user_id", "date", "weight_kg", "bmi", "body_fat_pct", "body_water_pct", "bone_mass_kg", "muscle_mass_kg", "physique_rating", "visceral_fat", "metabolic_age", "source"], [f"{user_id}-{date}", user_id, date, grams / 1000 if grams else None, _num(row.get("bmi")), _num(row.get("bodyFat")), _num(row.get("bodyWater")), (_num(row.get("boneMass")) or 0) / 1000 or None, (_num(row.get("muscleMass")) or 0) / 1000 or None, _num(row.get("physiqueRating")), _num(row.get("visceralFat")), _num(row.get("metabolicAge")), row.get("sourceType")], ) stored += 1 return stored def get_body_composition(user_id, start=None, end=None): sql = "WHERE user_id = ?" params = [user_id] if start: sql += " AND date >= ?" params.append(start) if end: sql += " AND date <= ?" params.append(end) rows = query_all( f"SELECT * FROM body_composition {sql} ORDER BY date ASC", params ) return [{ "date": str(r["date"])[:10], "weightKg": r["weight_kg"], "bmi": r["bmi"], "bodyFatPct": r["body_fat_pct"], "bodyWaterPct": r["body_water_pct"], "boneMassKg": r["bone_mass_kg"], "muscleMassKg": r["muscle_mass_kg"], "physiqueRating": r["physique_rating"], "visceralFat": r["visceral_fat"], "metabolicAge": r["metabolic_age"], } for r in rows] # --- blood pressure ---------------------------------------------------------- def sync_blood_pressure(client, user_id, start, end): data = _safe(lambda: client.get_blood_pressure(start, end), {}) or {} stored = 0 for summary in data.get("measurementSummaries") or []: for m in summary.get("measurements") or []: when = _stamp(m.get("measurementTimestampLocal") or m.get("measurementTimestampGMT")) if not when: continue _upsert( "blood_pressure", ("user_id", "measured_at"), ["id", "user_id", "measured_at", "systolic", "diastolic", "pulse", "note"], [f"{user_id}-{when}", user_id, when, _int(m.get("systolic")), _int(m.get("diastolic")), _int(m.get("pulse")), m.get("notes")], ) stored += 1 return stored def get_blood_pressure(user_id): rows = query_all( "SELECT * FROM blood_pressure WHERE user_id = ? ORDER BY measured_at DESC", [user_id], ) return [{ "measuredAt": str(r["measured_at"]), "systolic": r["systolic"], "diastolic": r["diastolic"], "pulse": r["pulse"], "note": r["note"], } for r in rows] # --- race predictions -------------------------------------------------------- def sync_race_predictions(client, user_id, start=None, end=None): data = _safe(lambda: client.get_race_predictions(start, end), None) rows = data if isinstance(data, list) else [data] if data else [] stored = 0 for row in rows: if not isinstance(row, dict): continue date = _day(row.get("calendarDate") or row.get("fromCalendarDate")) if not date: continue _upsert( "race_predictions", ("user_id", "date"), ["id", "user_id", "date", "time_5k", "time_10k", "time_half", "time_marathon"], [f"{user_id}-{date}", user_id, date, _int(row.get("time5K")), _int(row.get("time10K")), _int(row.get("timeHalfMarathon")), _int(row.get("timeMarathon"))], ) stored += 1 return stored def get_race_predictions(user_id, limit=90): rows = query_all( "SELECT * FROM race_predictions WHERE user_id = ? ORDER BY date DESC", [user_id], )[:limit] return [{ "date": str(r["date"])[:10], "time5k": r["time_5k"], "time10k": r["time_10k"], "timeHalf": r["time_half"], "timeMarathon": r["time_marathon"], } for r in reversed(rows)] # --- within-day series ------------------------------------------------------- # Each entry: the API call, and how to pull the [timestamp, value] pairs out of # whatever shape that particular endpoint returns. They are all different. def _hr_series(data): return [[_stamp(t), v] for t, v in (data.get("heartRateValues") or []) if v is not None] def _stress_series(data): return [[_stamp(t), v] for t, v in (data.get("stressValuesArray") or []) if v is not None and v >= 0] def _battery_series(data): out = [] for entry in data if isinstance(data, list) else [data]: for point in (entry or {}).get("bodyBatteryValuesArray") or []: # [timestamp, status, level, version] if len(point) >= 3 and point[2] is not None: out.append([_stamp(point[0]), point[2]]) return out def _respiration_series(data): return [[_stamp(t), v] for t, v in (data.get("respirationValuesArray") or []) if v is not None and v > 0] def _spo2_series(data): return [[_stamp(t), v] for t, v in (data.get("spO2HourlyAverages") or []) if v is not None] SERIES_KINDS = { "heartRate": (lambda c, d: c.get_heart_rates(d), _hr_series), "stress": (lambda c, d: c.get_all_day_stress(d), _stress_series), "bodyBattery": (lambda c, d: c.get_body_battery(d, d), _battery_series), "respiration": (lambda c, d: c.get_respiration_data(d), _respiration_series), "spo2": (lambda c, d: c.get_spo2_data(d), _spo2_series), } # A day of heart rate is ~500 samples at 2-minute resolution. More than this # cannot be told apart on a phone chart and only inflates the row. SERIES_MAX_POINTS = 240 def _thin(points, limit=SERIES_MAX_POINTS): if len(points) <= limit: return points step = (len(points) - 1) / (limit - 1) return [points[int(round(i * step))] for i in range(limit)] def sync_daily_series(client, user_id, date, kinds=None): """Store the within-day curves for one day.""" stored = 0 now = datetime.datetime.utcnow().isoformat(timespec="seconds") for kind, (call, extract) in SERIES_KINDS.items(): if kinds and kind not in kinds: continue raw = _safe(lambda: call(client, date)) if raw is None: continue points = _safe(lambda: _thin(extract(raw)), []) or [] if not points: continue _upsert( "daily_series", ("user_id", "date", "kind"), ["id", "user_id", "date", "kind", "payload", "fetched_at"], [f"{user_id}-{date}-{kind}", user_id, date, kind, json.dumps(points, default=str), now], ) stored += 1 return stored def get_daily_series(user_id, date): rows = query_all( "SELECT kind, payload FROM daily_series WHERE user_id = ? AND date = ?", [user_id, date], ) out = {} for r in rows: try: out[r["kind"]] = json.loads(r["payload"]) except (ValueError, TypeError): continue return out # --- challenges -------------------------------------------------------------- def sync_challenges(client, user_id): """Badge challenges and ad-hoc challenges. Distinct from badges: a badge is earned once and sits in a list, while a challenge has a period, a target and a standing. """ execute("DELETE FROM challenges WHERE user_id = ?", [user_id]) stored = 0 sources = [ ("badge", lambda: client.get_badge_challenges(1, 100)), ("adhoc", lambda: client.get_adhoc_challenges(1, 100)), ("available", lambda: client.get_available_badge_challenges(1, 100)), ("inprogress", lambda: client.get_inprogress_virtual_challenges(1, 100)), ] for kind, call in sources: rows = _safe(call, []) or [] if isinstance(rows, dict): rows = rows.get("challenges") or rows.get("badgeChallenges") or [] for row in rows: if not isinstance(row, dict): continue uuid = row.get("uuid") or row.get("challengeUuid") or row.get("badgeId") name = (row.get("badgeChallengeName") or row.get("adHocChallengeName") or row.get("challengeName") or row.get("badgeName")) execute( "INSERT INTO challenges (id, user_id, challenge_uuid, kind, name, " "status, start_date, end_date, payload) VALUES (?,?,?,?,?,?,?,?,?)", [f"{user_id}-{kind}-{uuid}-{stored}", user_id, str(uuid or ""), kind, name, str(row.get("badgeChallengeStatusId") or row.get("socialChallengeStatusId") or ""), _day(row.get("startDate")), _day(row.get("endDate")), json.dumps(row, default=str)], ) stored += 1 return stored def get_challenges(user_id): rows = query_all( "SELECT * FROM challenges WHERE user_id = ? ORDER BY start_date DESC", [user_id], ) out = [] for r in rows: try: payload = json.loads(r["payload"]) if r["payload"] else {} except (ValueError, TypeError): payload = {} out.append({ "uuid": r["challenge_uuid"], "kind": r["kind"], "name": r["name"], "status": r["status"], "startDate": str(r["start_date"])[:10] if r["start_date"] else None, "endDate": str(r["end_date"])[:10] if r["end_date"] else None, "payload": payload, }) return out # --- devices ----------------------------------------------------------------- def sync_devices(client, user_id): devices = _safe(lambda: client.get_devices(), []) or [] if isinstance(devices, dict): devices = [devices] execute("DELETE FROM devices WHERE user_id = ?", [user_id]) last_used = _safe(lambda: client.get_device_last_used(), {}) or {} stored = 0 for d in devices: if not isinstance(d, dict): continue device_id = str(d.get("deviceId") or d.get("unitId") or stored) execute( "INSERT INTO devices (id, user_id, device_id, name, model, serial, " "software_version, last_used_at, payload) VALUES (?,?,?,?,?,?,?,?,?)", [f"{user_id}-{device_id}", user_id, device_id, d.get("displayName") or d.get("productDisplayName"), d.get("productDisplayName") or d.get("partNumber"), str(d.get("serialNumber") or ""), str(d.get("softwareVersion") or ""), _stamp(last_used.get("lastUsedDeviceUploadTime")) if str(last_used.get("userDeviceId") or "") == device_id else None, json.dumps(d, default=str)], ) stored += 1 return stored def get_devices(user_id): rows = query_all("SELECT * FROM devices WHERE user_id = ?", [user_id]) return [{ "deviceId": r["device_id"], "name": r["name"], "model": r["model"], "serial": r["serial"], "softwareVersion": r["software_version"], "lastUsedAt": str(r["last_used_at"]) if r["last_used_at"] else None, } for r in rows] # --- per-day extras folded into health_data ---------------------------------- def daily_extras(client, date): """Hill score, hydration and weight for one day. Returned as columns to merge into the day's health_data row rather than stored separately — they are daily scalars like every other metric there. """ out = {} hydration = _safe(lambda: client.get_hydration_data(date), {}) or {} out["hydrationMl"] = _int(hydration.get("valueInML")) out["hydrationGoalMl"] = _int(hydration.get("goalInML")) out["sweatLossMl"] = _int(hydration.get("sweatLossInML")) hill = _safe(lambda: client.get_hill_score(date, date), {}) or {} scores = hill.get("hillScoreDTOList") or [] if scores: out["hillScore"] = _int(scores[-1].get("overallScore")) else: out["hillScore"] = _int(hill.get("periodAvgScore")) weigh = _safe(lambda: client.get_daily_weigh_ins(date), {}) or {} summaries = weigh.get("dateWeightList") or [] if summaries: grams = _num(summaries[-1].get("weight")) out["weightKg"] = grams / 1000 if grams else None out["bmi"] = _num(summaries[-1].get("bmi")) out["bodyFatPct"] = _num(summaries[-1].get("bodyFat")) return {k: v for k, v in out.items() if v is not None}