-
-
Notifications
You must be signed in to change notification settings - Fork 466
Add personal Garmin Connect dashboard #374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,161 @@ | ||||||||||||||
| #!/usr/bin/env python3 | ||||||||||||||
| """Personal Garmin Connect dashboard. | ||||||||||||||
|
|
||||||||||||||
| A small local web app showing today's health summary, recent daily | ||||||||||||||
| metrics, and recent activities, using the garminconnect library. | ||||||||||||||
|
|
||||||||||||||
| Dependencies: | ||||||||||||||
| pip install garminconnect curl_cffi flask | ||||||||||||||
|
|
||||||||||||||
| Environment Variables (optional): | ||||||||||||||
| export EMAIL=<your garmin email address> | ||||||||||||||
| export PASSWORD=<your garmin password> | ||||||||||||||
| export GARMINTOKENS=<path to token storage, default ~/.garminconnect> | ||||||||||||||
|
|
||||||||||||||
| Run: | ||||||||||||||
| python3 dashboard.py | ||||||||||||||
| # then open http://127.0.0.1:5000 | ||||||||||||||
| """ | ||||||||||||||
|
|
||||||||||||||
| import logging | ||||||||||||||
| import os | ||||||||||||||
| import sys | ||||||||||||||
| from datetime import date, timedelta | ||||||||||||||
| from getpass import getpass | ||||||||||||||
| from pathlib import Path | ||||||||||||||
|
|
||||||||||||||
| from flask import Flask, render_template | ||||||||||||||
|
|
||||||||||||||
| from garminconnect import ( | ||||||||||||||
| Garmin, | ||||||||||||||
| GarminConnectAuthenticationError, | ||||||||||||||
| GarminConnectConnectionError, | ||||||||||||||
| GarminConnectTooManyRequestsError, | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| logging.getLogger("garminconnect").setLevel(logging.CRITICAL) | ||||||||||||||
|
|
||||||||||||||
| app = Flask(__name__) | ||||||||||||||
| DAYS_HISTORY = 7 | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def init_api() -> Garmin | None: | ||||||||||||||
| """Authenticate with Garmin Connect, reusing saved tokens if available.""" | ||||||||||||||
| tokenstore = os.getenv("GARMINTOKENS", "~/.garminconnect") | ||||||||||||||
| tokenstore_path = str(Path(tokenstore).expanduser()) | ||||||||||||||
|
|
||||||||||||||
| try: | ||||||||||||||
| garmin = Garmin() | ||||||||||||||
| garmin.login(tokenstore_path) | ||||||||||||||
| print("Logged in using saved tokens.") | ||||||||||||||
| return garmin | ||||||||||||||
| except GarminConnectTooManyRequestsError as err: | ||||||||||||||
| print(f"Rate limit: {err}") | ||||||||||||||
| sys.exit(1) | ||||||||||||||
| except (GarminConnectAuthenticationError, GarminConnectConnectionError): | ||||||||||||||
| print("No valid tokens found — please log in.") | ||||||||||||||
|
|
||||||||||||||
| while True: | ||||||||||||||
| try: | ||||||||||||||
| email = os.getenv("EMAIL") or input("Email: ").strip() | ||||||||||||||
| password = os.getenv("PASSWORD") or getpass("Password: ") | ||||||||||||||
|
|
||||||||||||||
| garmin = Garmin( | ||||||||||||||
| email=email, | ||||||||||||||
| password=password, | ||||||||||||||
| prompt_mfa=lambda: input("MFA code: ").strip(), | ||||||||||||||
| ) | ||||||||||||||
| garmin.login(tokenstore_path) | ||||||||||||||
| print(f"Login successful. Tokens saved to: {tokenstore_path}") | ||||||||||||||
| return garmin | ||||||||||||||
| except GarminConnectTooManyRequestsError as err: | ||||||||||||||
| print(f"Rate limit: {err}") | ||||||||||||||
| sys.exit(1) | ||||||||||||||
| except GarminConnectAuthenticationError: | ||||||||||||||
| print("Wrong credentials — please try again.") | ||||||||||||||
| continue | ||||||||||||||
| except GarminConnectConnectionError as err: | ||||||||||||||
| print(f"Connection error: {err}") | ||||||||||||||
| return None | ||||||||||||||
| except KeyboardInterrupt: | ||||||||||||||
| return None | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def safe_call(fn, *args, default=None): | ||||||||||||||
| try: | ||||||||||||||
| return fn(*args) | ||||||||||||||
| except Exception as e: | ||||||||||||||
| print(f"Warning: {fn.__name__} failed: {e}") | ||||||||||||||
| return default | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def build_daily_history(api: Garmin, days: int) -> list[dict]: | ||||||||||||||
| history = [] | ||||||||||||||
| for i in range(days - 1, -1, -1): | ||||||||||||||
| d = (date.today() - timedelta(days=i)).isoformat() | ||||||||||||||
| summary = safe_call(api.get_user_summary, d, default={}) or {} | ||||||||||||||
| sleep = safe_call(api.get_sleep_data, d, default={}) or {} | ||||||||||||||
| sleep_seconds = ( | ||||||||||||||
| sleep.get("dailySleepDTO", {}).get("sleepTimeSeconds", 0) or 0 | ||||||||||||||
| ) | ||||||||||||||
|
Comment on lines
+98
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Possible
🛠️ Proposed fix- sleep_seconds = (
- sleep.get("dailySleepDTO", {}).get("sleepTimeSeconds", 0) or 0
- )
+ sleep_seconds = (
+ (sleep.get("dailySleepDTO") or {}).get("sleepTimeSeconds", 0) or 0
+ )📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| history.append( | ||||||||||||||
| { | ||||||||||||||
| "date": d, | ||||||||||||||
| "steps": summary.get("totalSteps", 0) or 0, | ||||||||||||||
| "resting_hr": summary.get("restingHeartRate"), | ||||||||||||||
| "stress": summary.get("averageStressLevel"), | ||||||||||||||
| "sleep_hours": round(sleep_seconds / 3600, 1), | ||||||||||||||
| "calories": summary.get("totalKilocalories", 0) or 0, | ||||||||||||||
| } | ||||||||||||||
| ) | ||||||||||||||
| return history | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def build_recent_activities(api: Garmin, limit: int = 10) -> list[dict]: | ||||||||||||||
| activities = safe_call(api.get_activities, 0, limit, default=[]) or [] | ||||||||||||||
| result = [] | ||||||||||||||
| for a in activities: | ||||||||||||||
| result.append( | ||||||||||||||
| { | ||||||||||||||
| "name": a.get("activityName", "Activity"), | ||||||||||||||
| "type": (a.get("activityType") or {}).get("typeKey", "n/a"), | ||||||||||||||
| "date": (a.get("startTimeLocal") or "")[:16], | ||||||||||||||
| "distance_km": round((a.get("distance") or 0) / 1000, 2), | ||||||||||||||
| "duration_min": round((a.get("duration") or 0) / 60, 1), | ||||||||||||||
| "avg_hr": a.get("averageHR"), | ||||||||||||||
| "calories": a.get("calories"), | ||||||||||||||
| } | ||||||||||||||
| ) | ||||||||||||||
| return result | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| @app.route("/") | ||||||||||||||
| def index(): | ||||||||||||||
| today = date.today().isoformat() | ||||||||||||||
| summary = safe_call(api.get_user_summary, today, default={}) or {} | ||||||||||||||
| history = build_daily_history(api, DAYS_HISTORY) | ||||||||||||||
| activities = build_recent_activities(api) | ||||||||||||||
|
|
||||||||||||||
| return render_template( | ||||||||||||||
| "dashboard.html", | ||||||||||||||
| today=today, | ||||||||||||||
| summary=summary, | ||||||||||||||
| history=history, | ||||||||||||||
| activities=activities, | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| api: Garmin | None = None | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def main(): | ||||||||||||||
| global api | ||||||||||||||
| api = init_api() | ||||||||||||||
| if not api: | ||||||||||||||
| print("Could not authenticate. Exiting.") | ||||||||||||||
| sys.exit(1) | ||||||||||||||
| app.run(debug=False, port=5000) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| if __name__ == "__main__": | ||||||||||||||
| main() | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| <!DOCTYPE html> | ||
| <html lang="fr"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <title>Garmin Dashboard</title> | ||
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win Pin the Chart.js version and add Subresource Integrity. Loading ♻️ Suggested change-<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"
+ integrity="sha384-<hash>" crossorigin="anonymous"></script>🤖 Prompt for AI Agents |
||
| <style> | ||
| body { font-family: -apple-system, Arial, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 2rem; } | ||
| h1 { font-weight: 600; } | ||
| .cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; } | ||
| .card { background: #1e293b; border-radius: 12px; padding: 1.2rem 1.5rem; min-width: 150px; } | ||
| .card .value { font-size: 1.8rem; font-weight: 700; } | ||
| .card .label { font-size: 0.85rem; color: #94a3b8; } | ||
| .charts { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 2rem; } | ||
| .chart-box { background: #1e293b; border-radius: 12px; padding: 1rem; flex: 1; min-width: 320px; } | ||
| table { width: 100%; border-collapse: collapse; background: #1e293b; border-radius: 12px; overflow: hidden; } | ||
| th, td { padding: 0.6rem 1rem; text-align: left; border-bottom: 1px solid #334155; font-size: 0.9rem; } | ||
| th { color: #94a3b8; font-weight: 500; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <h1>Garmin Dashboard — {{ today }}</h1> | ||
|
|
||
| <div class="cards"> | ||
| <div class="card"><div class="value">{{ summary.totalSteps or 0 }}</div><div class="label">Pas aujourd'hui</div></div> | ||
| <div class="card"><div class="value">{{ summary.restingHeartRate or '–' }}</div><div class="label">FC repos (bpm)</div></div> | ||
| <div class="card"><div class="value">{{ summary.averageStressLevel or '–' }}</div><div class="label">Stress moyen</div></div> | ||
| <div class="card"><div class="value">{{ "%.0f"|format(summary.totalKilocalories or 0) }}</div><div class="label">Calories</div></div> | ||
| </div> | ||
|
|
||
| <div class="charts"> | ||
| <div class="chart-box"><canvas id="stepsChart"></canvas></div> | ||
| <div class="chart-box"><canvas id="sleepChart"></canvas></div> | ||
| <div class="chart-box"><canvas id="hrChart"></canvas></div> | ||
| </div> | ||
|
|
||
| <h2>Activités récentes</h2> | ||
| <table> | ||
| <thead> | ||
| <tr><th>Date</th><th>Nom</th><th>Type</th><th>Distance (km)</th><th>Durée (min)</th><th>FC moy.</th><th>Calories</th></tr> | ||
| </thead> | ||
| <tbody> | ||
| {% for a in activities %} | ||
| <tr> | ||
| <td>{{ a.date }}</td> | ||
| <td>{{ a.name }}</td> | ||
| <td>{{ a.type }}</td> | ||
| <td>{{ a.distance_km }}</td> | ||
| <td>{{ a.duration_min }}</td> | ||
| <td>{{ a.avg_hr or '–' }}</td> | ||
| <td>{{ a.calories or '–' }}</td> | ||
| </tr> | ||
| {% endfor %} | ||
| </tbody> | ||
| </table> | ||
|
|
||
| <script> | ||
| const history = {{ history | tojson }}; | ||
| const labels = history.map(h => h.date.slice(5)); | ||
|
|
||
| new Chart(document.getElementById('stepsChart'), { | ||
| type: 'bar', | ||
| data: { labels, datasets: [{ label: 'Pas', data: history.map(h => h.steps), backgroundColor: '#38bdf8' }] }, | ||
| options: { plugins: { title: { display: true, text: 'Pas / jour', color: '#e2e8f0' }, legend: { display: false } }, | ||
| scales: { x: { ticks: { color: '#94a3b8' } }, y: { ticks: { color: '#94a3b8' } } } } | ||
| }); | ||
|
|
||
| new Chart(document.getElementById('sleepChart'), { | ||
| type: 'line', | ||
| data: { labels, datasets: [{ label: 'Sommeil (h)', data: history.map(h => h.sleep_hours), borderColor: '#a78bfa', tension: 0.3 }] }, | ||
| options: { plugins: { title: { display: true, text: 'Sommeil / jour', color: '#e2e8f0' }, legend: { display: false } }, | ||
| scales: { x: { ticks: { color: '#94a3b8' } }, y: { ticks: { color: '#94a3b8' } } } } | ||
| }); | ||
|
|
||
| new Chart(document.getElementById('hrChart'), { | ||
| type: 'line', | ||
| data: { labels, datasets: [{ label: 'FC repos', data: history.map(h => h.resting_hr), borderColor: '#f87171', tension: 0.3 }] }, | ||
| options: { plugins: { title: { display: true, text: 'FC repos / jour', color: '#e2e8f0' }, legend: { display: false } }, | ||
| scales: { x: { ticks: { color: '#94a3b8' } }, y: { ticks: { color: '#94a3b8' } } } } | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Infinite loop when
EMAIL/PASSWORDenv vars hold invalid credentials.Inside the retry loop,
os.getenv("EMAIL") or input(...)andos.getenv("PASSWORD") or getpass(...)always resolve to the same env values when set. If those credentials are wrong,GarminConnectAuthenticationErrortriggerscontinue, which re-reads the identical env values forever, printing "Wrong credentials" in a tight loop with no way to break (input is never reached).Re-prompt interactively (or abort) when env-supplied credentials fail.
🛠️ Proposed fix
while True: try: - email = os.getenv("EMAIL") or input("Email: ").strip() - password = os.getenv("PASSWORD") or getpass("Password: ") + email = input("Email: ").strip() if not env_used else os.environ["EMAIL"] + password = getpass("Password: ") if not env_used else os.environ["PASSWORD"] @@ except GarminConnectAuthenticationError: print("Wrong credentials — please try again.") + env_used = False # fall back to interactive entry continue(Initialize
env_used = bool(os.getenv("EMAIL") and os.getenv("PASSWORD"))before the loop.)🤖 Prompt for AI Agents