Files
homelab/clusters/homelab/apps/garmin/configmap.yaml
T

324 lines
12 KiB
YAML

apiVersion: v1
kind: ConfigMap
metadata:
name: garmin-sync-script
namespace: garmin
data:
sync.py: |
import os
import time
from datetime import date, timedelta, datetime
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):
try:
client.login()
print(f"Login erfolgreich (Versuch {attempt + 1})")
break
except Exception as e:
if "429" in str(e):
wait = 60 * (attempt + 1)
print(f"Rate limited (429), warte {wait}s...")
time.sleep(wait)
else:
raise
else:
raise Exception("Login nach 5 Versuchen fehlgeschlagen")
today = date.today()
start = today - timedelta(days=365 * 3)
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
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS daily_health (
date DATE PRIMARY KEY,
resting_hr INTEGER,
min_hr INTEGER,
max_hr INTEGER,
total_steps INTEGER,
daily_step_goal INTEGER,
total_distance_meters FLOAT,
active_calories FLOAT,
total_calories FLOAT,
bmr_calories FLOAT,
highly_active_seconds INTEGER,
active_seconds INTEGER,
sedentary_seconds INTEGER,
sleeping_seconds INTEGER,
moderate_intensity_minutes INTEGER,
vigorous_intensity_minutes INTEGER,
floors_ascended FLOAT,
floors_descended FLOAT,
average_stress INTEGER,
max_stress INTEGER,
rest_stress_duration INTEGER,
low_stress_duration INTEGER,
medium_stress_duration INTEGER,
high_stress_duration INTEGER,
stress_qualifier TEXT,
body_battery_highest INTEGER,
body_battery_lowest INTEGER,
body_battery_most_recent INTEGER,
body_battery_at_wake INTEGER,
body_battery_during_sleep INTEGER,
average_spo2 FLOAT,
lowest_spo2 FLOAT,
latest_spo2 FLOAT,
hrv_weekly_avg FLOAT,
hrv_last_night FLOAT,
hrv_last_night_5min_high FLOAT,
hrv_baseline_low FLOAT,
hrv_baseline_high FLOAT,
hrv_status TEXT,
hrv_feedback TEXT,
sleep_start_local TIMESTAMP,
sleep_end_local TIMESTAMP,
sleep_duration_seconds INTEGER,
sleep_nap_seconds INTEGER,
sleep_deep_seconds INTEGER,
sleep_light_seconds INTEGER,
sleep_rem_seconds INTEGER,
sleep_awake_seconds INTEGER,
sleep_rem_capable BOOLEAN,
sleep_score INTEGER,
sleep_feedback TEXT,
respiration_avg_waking FLOAT,
respiration_avg_sleep FLOAT,
respiration_lowest FLOAT,
respiration_highest FLOAT
)
""")
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 ts_to_dt(ts):
if ts is None:
return None
if ts > 1e10:
ts = ts / 1000
return datetime.fromtimestamp(ts)
def safe_get(fn, *args):
try:
return fn(*args)
except Exception as e:
print(f" Warnung {fn.__name__}: {e}")
return None
COLS = """
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
"""
N = len([c for c in COLS.split(",") if c.strip()]) # = 55
PLACEHOLDERS = ",".join(["%s"] * N)
UPDATE = ", ".join(
f"{c.strip()} = EXCLUDED.{c.strip()}"
for c in COLS.split(",")
if c.strip() and c.strip() != "date"
)
INSERT_SQL = f"""
INSERT INTO daily_health ({COLS})
VALUES ({PLACEHOLDERS})
ON CONFLICT (date) DO UPDATE SET {UPDATE}
"""
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 {}
values = (
check_date,
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"),
hrv.get("weeklyAvg"),
hrv.get("lastNight"),
hrv.get("lastNight5MinHigh"),
hrv.get("baselineLowUpper"),
hrv.get("baselineBalancedLower"),
hrv.get("status"),
hrv.get("feedbackPhrase"),
ts_to_dt(sleep.get("sleepStartTimestampLocal")),
ts_to_dt(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"),
resp.get("avgWakingRespirationValue"),
resp.get("avgSleepRespirationValue"),
resp.get("lowestRespirationValue"),
resp.get("highestRespirationValue"),
)
assert len(values) == N, f"Mismatch: {len(values)} values vs {N} columns"
cur.execute(INSERT_SQL, values)
conn.commit()
synced += 1
check_date += timedelta(days=1)
time.sleep(0.5)
cur.close()
conn.close()
print(f"Synced {synced} daily health records")