diff --git a/clusters/homelab/apps/garmin/configmap.yaml b/clusters/homelab/apps/garmin/configmap.yaml index 7d53458..56e3bc2 100644 --- a/clusters/homelab/apps/garmin/configmap.yaml +++ b/clusters/homelab/apps/garmin/configmap.yaml @@ -6,13 +6,14 @@ metadata: data: sync.py: | import os - import json import time from datetime import date, timedelta + import psycopg2 from garminconnect import Garmin email = os.environ["GARMIN_EMAIL"] password = os.environ["GARMIN_PASSWORD"] + db_url = os.environ["DATABASE_URL"] client = Garmin(email, password) for attempt in range(5): @@ -30,21 +31,367 @@ data: else: raise Exception("Login nach 5 Versuchen fehlgeschlagen") - d = (date.today() - timedelta(days=1)).isoformat() - print(f"=== Debug fuer {d} ===") + today = date.today() + start = today - timedelta(days=365 * 3) - for name, fn, args in [ - ("stats", client.get_stats, [d]), - ("stress", client.get_stress_data, [d]), - ("bb", client.get_body_battery, [d, d]), - ("spo2", client.get_spo2_data, [d]), - ("resp", client.get_respiration_data, [d]), - ("hrv", client.get_hrv_data, [d]), - ("sleep", client.get_sleep_data, [d]), - ]: + conn = psycopg2.connect(db_url) + cur = conn.cursor() + + # ── Schema ──────────────────────────────────────────────────────────────── + + cur.execute(""" + CREATE TABLE IF NOT EXISTS activities ( + id BIGINT PRIMARY KEY, + name TEXT, + type TEXT, + start_time TIMESTAMP, + duration_seconds FLOAT, + distance_meters FLOAT, + avg_hr INTEGER, + max_hr INTEGER, + avg_pace FLOAT, + vo2max FLOAT, + calories FLOAT, + elevation_gain FLOAT, + steps INTEGER, + cadence FLOAT, + hr_zone_1 FLOAT, + hr_zone_2 FLOAT, + hr_zone_3 FLOAT, + hr_zone_4 FLOAT, + hr_zone_5 FLOAT + ) + """) + + # Keys verified: (D) = aus deinem Debug-Output direkt bestaetigt + # (S) = aus Quellcode/Pydantic-Models + cur.execute(""" + CREATE TABLE IF NOT EXISTS daily_health ( + date DATE PRIMARY KEY, + + -- get_stats (D) + resting_hr INTEGER, -- restingHeartRate + min_hr INTEGER, -- minHeartRate + max_hr INTEGER, -- maxHeartRate + total_steps INTEGER, -- totalSteps + daily_step_goal INTEGER, -- dailyStepGoal + total_distance_meters FLOAT, -- totalDistanceMeters + active_calories FLOAT, -- activeKilocalories + total_calories FLOAT, -- totalKilocalories + bmr_calories FLOAT, -- bmrKilocalories + highly_active_seconds INTEGER, -- highlyActiveSeconds + active_seconds INTEGER, -- activeSeconds + sedentary_seconds INTEGER, -- sedentarySeconds + sleeping_seconds INTEGER, -- sleepingSeconds + moderate_intensity_minutes INTEGER, -- moderateIntensityMinutes + vigorous_intensity_minutes INTEGER, -- vigorousIntensityMinutes + floors_ascended FLOAT, -- floorsAscended + floors_descended FLOAT, -- floorsDescended + average_stress INTEGER, -- averageStressLevel + max_stress INTEGER, -- maxStressLevel + rest_stress_duration INTEGER, -- restStressDuration + low_stress_duration INTEGER, -- lowStressDuration + medium_stress_duration INTEGER, -- mediumStressDuration + high_stress_duration INTEGER, -- highStressDuration + stress_qualifier TEXT, -- stressQualifier + body_battery_highest INTEGER, -- bodyBatteryHighestValue + body_battery_lowest INTEGER, -- bodyBatteryLowestValue + body_battery_most_recent INTEGER, -- bodyBatteryMostRecentValue + body_battery_at_wake INTEGER, -- bodyBatteryAtWakeTime + body_battery_during_sleep INTEGER, -- bodyBatteryDuringSleep + average_spo2 FLOAT, -- averageSpo2 + lowest_spo2 FLOAT, -- lowestSpo2 + latest_spo2 FLOAT, -- latestSpo2 + + -- get_hrv_data -> hrvSummary (S - wird null wenn Uhr nicht getragen) + hrv_weekly_avg FLOAT, -- weeklyAvg + hrv_last_night FLOAT, -- lastNight + hrv_last_night_5min_high FLOAT, -- lastNight5MinHigh + hrv_baseline_low FLOAT, -- baselineLowUpper + hrv_baseline_high FLOAT, -- baselineBalancedLower + hrv_status TEXT, -- status + hrv_feedback TEXT, -- feedbackPhrase + + -- get_sleep_data -> dailySleepDTO (D) + sleep_start_local TIMESTAMP, -- sleepStartTimestampLocal + sleep_end_local TIMESTAMP, -- sleepEndTimestampLocal + sleep_duration_seconds INTEGER, -- sleepTimeSeconds + sleep_nap_seconds INTEGER, -- napTimeSeconds + sleep_deep_seconds INTEGER, -- deepSleepSeconds + sleep_light_seconds INTEGER, -- lightSleepSeconds + sleep_rem_seconds INTEGER, -- remSleepSeconds + sleep_awake_seconds INTEGER, -- awakeSleepSeconds + sleep_rem_capable BOOLEAN, -- deviceRemCapable + sleep_score INTEGER, -- sleepScores.overall.value + sleep_feedback TEXT, -- sleepScores.overall.qualifierKey + + -- get_respiration_data (D) + respiration_avg_waking FLOAT, -- avgWakingRespirationValue + respiration_avg_sleep FLOAT, -- avgSleepRespirationValue + respiration_lowest FLOAT, -- lowestRespirationValue + respiration_highest FLOAT -- highestRespirationValue + ) + """) + + conn.commit() + + # ── Activities ──────────────────────────────────────────────────────────── + + activities = client.get_activities_by_date( + start.isoformat(), today.isoformat() + ) + + for a in activities: + cur.execute(""" + INSERT INTO activities + (id, name, type, start_time, duration_seconds, distance_meters, + avg_hr, max_hr, avg_pace, vo2max, calories, elevation_gain, + steps, cadence, hr_zone_1, hr_zone_2, hr_zone_3, hr_zone_4, hr_zone_5) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON CONFLICT (id) DO UPDATE SET + duration_seconds = EXCLUDED.duration_seconds, + distance_meters = EXCLUDED.distance_meters, + avg_hr = EXCLUDED.avg_hr, + max_hr = EXCLUDED.max_hr, + avg_pace = EXCLUDED.avg_pace, + vo2max = EXCLUDED.vo2max, + calories = EXCLUDED.calories, + elevation_gain = EXCLUDED.elevation_gain, + steps = EXCLUDED.steps, + cadence = EXCLUDED.cadence, + hr_zone_1 = EXCLUDED.hr_zone_1, + hr_zone_2 = EXCLUDED.hr_zone_2, + hr_zone_3 = EXCLUDED.hr_zone_3, + hr_zone_4 = EXCLUDED.hr_zone_4, + hr_zone_5 = EXCLUDED.hr_zone_5 + """, ( + a.get("activityId"), + a.get("activityName"), + a.get("activityType", {}).get("typeKey"), + a.get("startTimeLocal"), + a.get("duration"), + a.get("distance"), + a.get("averageHR"), + a.get("maxHR"), + a.get("averageSpeed"), + a.get("vO2MaxValue"), + a.get("calories"), + a.get("elevationGain"), + a.get("steps"), + a.get("averageRunningCadenceInStepsPerMinute"), + a.get("hrTimeInZone_1"), + a.get("hrTimeInZone_2"), + a.get("hrTimeInZone_3"), + a.get("hrTimeInZone_4"), + a.get("hrTimeInZone_5"), + )) + + conn.commit() + print(f"Synced {len(activities)} activities") + + # ── Daily Health (letzte 7 Tage) ────────────────────────────────────────── + + def safe_get(fn, *args): try: - result = fn(*args) - print(f"\n=== {name} ===") - print(json.dumps(result, indent=2, default=str)[:3000]) + return fn(*args) except Exception as e: - print(f"\n=== {name} FEHLER: {e} ===") \ No newline at end of file + print(f" Warnung {fn.__name__}: {e}") + return None + + synced = 0 + check_date = today - timedelta(days=7) + + while check_date <= today: + d = check_date.isoformat() + + stats = safe_get(client.get_stats, d) or {} + hrv_raw = safe_get(client.get_hrv_data, d) or {} + sleep_r = safe_get(client.get_sleep_data, d) or {} + resp = safe_get(client.get_respiration_data, d) or {} + + hrv = hrv_raw.get("hrvSummary") or {} + sleep = sleep_r.get("dailySleepDTO") or {} + scores = ((sleep.get("sleepScores") or {}).get("overall") or {}) + + cur.execute(""" + INSERT INTO daily_health ( + date, + resting_hr, min_hr, max_hr, + total_steps, daily_step_goal, total_distance_meters, + active_calories, total_calories, bmr_calories, + highly_active_seconds, active_seconds, + sedentary_seconds, sleeping_seconds, + moderate_intensity_minutes, vigorous_intensity_minutes, + floors_ascended, floors_descended, + average_stress, max_stress, + rest_stress_duration, low_stress_duration, + medium_stress_duration, high_stress_duration, + stress_qualifier, + body_battery_highest, body_battery_lowest, + body_battery_most_recent, body_battery_at_wake, + body_battery_during_sleep, + average_spo2, lowest_spo2, latest_spo2, + hrv_weekly_avg, hrv_last_night, hrv_last_night_5min_high, + hrv_baseline_low, hrv_baseline_high, + hrv_status, hrv_feedback, + sleep_start_local, sleep_end_local, + sleep_duration_seconds, sleep_nap_seconds, + sleep_deep_seconds, sleep_light_seconds, + sleep_rem_seconds, sleep_awake_seconds, + sleep_rem_capable, sleep_score, sleep_feedback, + respiration_avg_waking, respiration_avg_sleep, + respiration_lowest, respiration_highest + ) VALUES ( + %s, + %s,%s,%s, + %s,%s,%s, + %s,%s,%s, + %s,%s, + %s,%s, + %s,%s, + %s,%s, + %s,%s, + %s,%s, + %s,%s, + %s, + %s,%s, + %s,%s, + %s, + %s,%s,%s, + %s,%s,%s, + %s,%s, + %s,%s, + %s,%s, + %s,%s, + %s,%s, + %s,%s,%s, + %s,%s,%s,%s, + %s,%s,%s, + %s,%s, + %s,%s + ) + ON CONFLICT (date) DO UPDATE SET + resting_hr = EXCLUDED.resting_hr, + min_hr = EXCLUDED.min_hr, + max_hr = EXCLUDED.max_hr, + total_steps = EXCLUDED.total_steps, + daily_step_goal = EXCLUDED.daily_step_goal, + total_distance_meters = EXCLUDED.total_distance_meters, + active_calories = EXCLUDED.active_calories, + total_calories = EXCLUDED.total_calories, + bmr_calories = EXCLUDED.bmr_calories, + highly_active_seconds = EXCLUDED.highly_active_seconds, + active_seconds = EXCLUDED.active_seconds, + sedentary_seconds = EXCLUDED.sedentary_seconds, + sleeping_seconds = EXCLUDED.sleeping_seconds, + moderate_intensity_minutes = EXCLUDED.moderate_intensity_minutes, + vigorous_intensity_minutes = EXCLUDED.vigorous_intensity_minutes, + floors_ascended = EXCLUDED.floors_ascended, + floors_descended = EXCLUDED.floors_descended, + average_stress = EXCLUDED.average_stress, + max_stress = EXCLUDED.max_stress, + rest_stress_duration = EXCLUDED.rest_stress_duration, + low_stress_duration = EXCLUDED.low_stress_duration, + medium_stress_duration = EXCLUDED.medium_stress_duration, + high_stress_duration = EXCLUDED.high_stress_duration, + stress_qualifier = EXCLUDED.stress_qualifier, + body_battery_highest = EXCLUDED.body_battery_highest, + body_battery_lowest = EXCLUDED.body_battery_lowest, + body_battery_most_recent = EXCLUDED.body_battery_most_recent, + body_battery_at_wake = EXCLUDED.body_battery_at_wake, + body_battery_during_sleep = EXCLUDED.body_battery_during_sleep, + average_spo2 = EXCLUDED.average_spo2, + lowest_spo2 = EXCLUDED.lowest_spo2, + latest_spo2 = EXCLUDED.latest_spo2, + hrv_weekly_avg = EXCLUDED.hrv_weekly_avg, + hrv_last_night = EXCLUDED.hrv_last_night, + hrv_last_night_5min_high = EXCLUDED.hrv_last_night_5min_high, + hrv_baseline_low = EXCLUDED.hrv_baseline_low, + hrv_baseline_high = EXCLUDED.hrv_baseline_high, + hrv_status = EXCLUDED.hrv_status, + hrv_feedback = EXCLUDED.hrv_feedback, + sleep_start_local = EXCLUDED.sleep_start_local, + sleep_end_local = EXCLUDED.sleep_end_local, + sleep_duration_seconds = EXCLUDED.sleep_duration_seconds, + sleep_nap_seconds = EXCLUDED.sleep_nap_seconds, + sleep_deep_seconds = EXCLUDED.sleep_deep_seconds, + sleep_light_seconds = EXCLUDED.sleep_light_seconds, + sleep_rem_seconds = EXCLUDED.sleep_rem_seconds, + sleep_awake_seconds = EXCLUDED.sleep_awake_seconds, + sleep_rem_capable = EXCLUDED.sleep_rem_capable, + sleep_score = EXCLUDED.sleep_score, + sleep_feedback = EXCLUDED.sleep_feedback, + respiration_avg_waking = EXCLUDED.respiration_avg_waking, + respiration_avg_sleep = EXCLUDED.respiration_avg_sleep, + respiration_lowest = EXCLUDED.respiration_lowest, + respiration_highest = EXCLUDED.respiration_highest + """, ( + check_date, + # get_stats + stats.get("restingHeartRate"), + stats.get("minHeartRate"), + stats.get("maxHeartRate"), + stats.get("totalSteps"), + stats.get("dailyStepGoal"), + stats.get("totalDistanceMeters"), + stats.get("activeKilocalories"), + stats.get("totalKilocalories"), + stats.get("bmrKilocalories"), + stats.get("highlyActiveSeconds"), + stats.get("activeSeconds"), + stats.get("sedentarySeconds"), + stats.get("sleepingSeconds"), + stats.get("moderateIntensityMinutes"), + stats.get("vigorousIntensityMinutes"), + stats.get("floorsAscended"), + stats.get("floorsDescended"), + stats.get("averageStressLevel"), + stats.get("maxStressLevel"), + stats.get("restStressDuration"), + stats.get("lowStressDuration"), + stats.get("mediumStressDuration"), + stats.get("highStressDuration"), + stats.get("stressQualifier"), + stats.get("bodyBatteryHighestValue"), + stats.get("bodyBatteryLowestValue"), + stats.get("bodyBatteryMostRecentValue"), + stats.get("bodyBatteryAtWakeTime"), + stats.get("bodyBatteryDuringSleep"), + stats.get("averageSpo2"), + stats.get("lowestSpo2"), + stats.get("latestSpo2"), + # get_hrv_data + hrv.get("weeklyAvg"), + hrv.get("lastNight"), + hrv.get("lastNight5MinHigh"), + hrv.get("baselineLowUpper"), + hrv.get("baselineBalancedLower"), + hrv.get("status"), + hrv.get("feedbackPhrase"), + # get_sleep_data + sleep.get("sleepStartTimestampLocal"), + sleep.get("sleepEndTimestampLocal"), + sleep.get("sleepTimeSeconds"), + sleep.get("napTimeSeconds"), + sleep.get("deepSleepSeconds"), + sleep.get("lightSleepSeconds"), + sleep.get("remSleepSeconds"), + sleep.get("awakeSleepSeconds"), + sleep.get("deviceRemCapable"), + scores.get("value"), + scores.get("qualifierKey"), + # get_respiration_data + resp.get("avgWakingRespirationValue"), + resp.get("avgSleepRespirationValue"), + resp.get("lowestRespirationValue"), + resp.get("highestRespirationValue"), + )) + + conn.commit() + synced += 1 + check_date += timedelta(days=1) + time.sleep(0.5) + + cur.close() + conn.close() + print(f"Synced {synced} daily health records") \ No newline at end of file