Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions dashboard.py
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
Comment on lines +58 to +76

Copy link
Copy Markdown
Contributor

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/PASSWORD env vars hold invalid credentials.

Inside the retry loop, os.getenv("EMAIL") or input(...) and os.getenv("PASSWORD") or getpass(...) always resolve to the same env values when set. If those credentials are wrong, GarminConnectAuthenticationError triggers continue, 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dashboard.py` around lines 58 - 76, The login retry loop in dashboard.py can
spin forever when EMAIL/PASSWORD are set to invalid values because the same env
credentials are reused after GarminConnectAuthenticationError. Update the login
flow around the Garmin login block to detect when env-supplied credentials were
used and, on failure, either stop retrying or fall back to interactive prompts
instead of continuing with the same values. Use the existing
GarminConnectAuthenticationError handling in the while True loop to gate the
retry behavior.

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

Copy link
Copy Markdown
Contributor

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

Possible AttributeError when dailySleepDTO is null.

sleep.get("dailySleepDTO", {}) only falls back to {} when the key is absent. Garmin's sleep response frequently includes "dailySleepDTO": null (e.g., days with no sleep data), in which case this returns None and the chained .get("sleepTimeSeconds", 0) raises AttributeError. Since build_daily_history is called directly in index() (not wrapped by safe_call), this crashes the request.

🛠️ 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sleep_seconds = (
sleep.get("dailySleepDTO", {}).get("sleepTimeSeconds", 0) or 0
)
sleep_seconds = (
(sleep.get("dailySleepDTO") or {}).get("sleepTimeSeconds", 0) or 0
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dashboard.py` around lines 98 - 100, The `build_daily_history` logic in
`dashboard.py` can crash when `sleep["dailySleepDTO"]` is null because the
chained `.get("sleepTimeSeconds", 0)` is called on `None`. Update the
`sleep_seconds` extraction to safely handle a null `dailySleepDTO` by
normalizing it to an empty mapping before reading `sleepTimeSeconds`, using the
`build_daily_history` flow that feeds `index()`.

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()
83 changes: 83 additions & 0 deletions templates/dashboard.html
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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 chart.js from a version-less CDN URL means a future major release can silently break the dashboard, and without an SRI hash a compromised CDN response would execute unverified script. Pin a specific version and add integrity/crossorigin.

♻️ 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@templates/dashboard.html` at line 6, The Chart.js script include in the
dashboard template is using an unpinned CDN URL without Subresource Integrity,
so update the existing script tag in the dashboard HTML to load a specific
Chart.js version and add the appropriate integrity and crossorigin attributes.
Locate the external Chart.js reference by its script src and replace it with the
versioned, verified CDN asset so the dashboard remains stable and the browser
can validate the download.

<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>